sdxc

Type to search, or start from one of these:

[ Building Remix apps ]

Build the interface with remix/ui

Compose pages from @sdxc/ui components, lay them out with @sdxc/u mixins, and hydrate only the island that needs script.

Last updated 2026-09-29

A page in a Remix v3 app is remix/ui JSX rendered on the server. This guide builds a projects page from @sdxc/ui components: a header, a grid of cards, and a dialog holding a form. It lays them out with @sdxc/u mixins, adds glyphs from @sdxc/icons, and hydrates exactly one small island, a copy-link button, since that is the only part of the page the platform cannot do on its own.

npm add remix @sdxc/ui @sdxc/u @sdxc/icons

The page works with no client JavaScript at all. The dialog opens, the form submits and the errors render through HTML the browser already understands, and script is an addition you make where it earns its place.

The stylesheets

Components read semantic --ui-* variables, and those derive from five palette scales you define: brand, neutral, danger, warning and success, each from 50 to 950. Load the reset, your palette, then the theme, in that order, from the document layout.

resources/css/colors.css
:root {
	--ui-color-brand-50: oklch(0.97 0.02 250);
	--ui-color-brand-500: oklch(0.6 0.18 250);
	--ui-color-brand-950: oklch(0.22 0.08 250);
	/* the same 50–950 shape for neutral, danger, warning and success */
}
resources/layouts/document.tsx
import type { Handle, RemixNode } from "remix/ui";

import resetStyles from "@sdxc/ui/reset.css?url";
import themeStyles from "@sdxc/ui/theme.css?url";

import colorStyles from "~/resources/css/colors.css?url";

interface Props {
	title: string;
	children: RemixNode;
}

export default function DocumentLayout(handle: Handle<Props>) {
	return () => (
		<html lang="en" class="system">
			<head>
				<meta charSet="utf-8" />
				<title>{handle.props.title}</title>
				<link rel="stylesheet" href={resetStyles} />
				<link rel="stylesheet" href={colorStyles} />
				<link rel="stylesheet" href={themeStyles} />
			</head>
			<body>{handle.props.children}</body>
		</html>
	);
}

The ?url suffix is Vite's: it emits each file and hands you its public path. Put the class system on <html> and the theme follows the visitor's prefers-color-scheme, with no script deciding the scheme and nothing flashing on first paint.

Compose a card

Every @sdxc/ui component is a remix/ui component used as JSX, and compound parts such as Card.Header hang off the root. A component of your own composes them:

resources/components/project-card.tsx
import type { Handle } from "remix/ui";

import { Badge, Card, LinkButton } from "@sdxc/ui";

export interface Project {
	name: string;
	summary: string;
	href: string;
	archived: boolean;
}

interface Props {
	project: Project;
}

export function ProjectCard(handle: Handle<Props>) {
	return () => {
		let { project } = handle.props;

		return (
			<Card>
				<Card.Header>
					<Card.Title>{project.name}</Card.Title>
					<Card.Description>{project.summary}</Card.Description>
				</Card.Header>
				<Card.Footer>
					{project.archived ? (
						<Badge color="neutral">Archived</Badge>
					) : null}
					<LinkButton
						href={project.href}
						variant="outline"
						color="neutral"
						size="sm"
					>
						Open
					</LinkButton>
				</Card.Footer>
			</Card>
		);
	};
}

Props such as color, variant and size become data-* attributes and a stylesheet rule paints the rest, so this renders as static HTML. color takes one of the five semantic roles, never a raw color, which is what lets two apps with different palettes share the components.

Layout with mixins

@sdxc/u covers what a component does not: the space between components, page width, and responsive behavior. Every export is a mixin, and a mix array composes them.

resources/components/project-grid.tsx
import type { Handle } from "remix/ui";

import { container, gap, grid, gridTemplate, vstack } from "@sdxc/u/layout";
import { at } from "@sdxc/u/responsive";

import type { Project } from "~/resources/components/project-card";

import { ProjectCard } from "~/resources/components/project-card";

export function ProjectGrid(handle: Handle<{ projects: Project[] }>) {
	return () => (
		<section mix={[vstack({ gap: 6 }), container("projects")]}>
			<ul
				mix={[
					grid(),
					gap(4),
					gridTemplate({ columns: "1fr" }),
					at("md", gridTemplate({ columns: "repeat(2, minmax(0, 1fr))" })),
				]}
			>
				{handle.props.projects.map((project) => (
					<li key={project.href}>
						<ProjectCard project={project} />
					</li>
				))}
			</ul>
		</section>
	);
}

at() is a container query, not a media query. container("projects") on the section makes it the thing the grid measures, so the same list shows two columns in a wide main area and one inside a narrow sidebar, whatever the viewport. Narrow is the unwrapped case, and each at() layers on above its breakpoint.

Reach for a component before a pile of mixins. When @sdxc/ui has the element, the component already carries its spacing, focus ring and states. When it is close but not exact, pass a small css({...}) from remix/ui in its mix rather than rebuilding it.

A dialog with no script

The native <dialog> element and Invoker Commands open and close a modal declaratively. A button names its target with commandfor and the verb with command, and there is no open-state for you to track.

resources/components/new-project-dialog.tsx
import type { Handle } from "remix/ui";

import { PlusIcon } from "@sdxc/icons";
import { Button, Dialog, Form, TextField } from "@sdxc/ui";

import routes from "~/routes/web";

export function NewProjectDialog(
	handle: Handle<{ issues?: ReadonlyArray<Form.Issue> }>,
) {
	return () => (
		<>
			<Button commandfor="new-project" command="show-modal">
				<PlusIcon size={16} />
				New project
			</Button>
			<Dialog
				id="new-project"
				aria-labelledby="new-project-title"
				open={handle.props.issues !== undefined}
			>
				<Dialog.Header>
					<Dialog.Title id="new-project-title">New project</Dialog.Title>
				</Dialog.Header>
				<Form
					method="post"
					action={routes.projects.action.href()}
					issues={handle.props.issues}
				>
					<TextField name="name" label="Name" required />
					<Dialog.Footer>
						<Button
							type="button"
							commandfor="new-project"
							command="close"
							variant="outline"
						>
							Cancel
						</Button>
						<Button type="submit">Create</Button>
					</Dialog.Footer>
				</Form>
				<Dialog.Close commandfor="new-project" aria-label="Close" />
			</Dialog>
		</>
	);
}

The icon renders with aria-hidden="true" beside the visible label, and its stroke is currentColor, so it takes the button's text color in every state. An icon-only button, such as Dialog.Close, needs an aria-label instead.

When the action refuses the submission and re-renders the page with issues, the open attribute shows the dialog again with each field's error beside it. A dialog opened by the attribute is non-modal and sits in the page's flow with no ::backdrop, so give that state a fixed position through mix if it should look like the modal the visitor submitted from. That is the whole loop: see Validate forms and route params for the action side.

An island, only where it's needed

Copying a URL to the clipboard is behavior the platform has no declarative form for, so that one button becomes a remix/ui client entry. The @sdxc/ui side of it is small: a Button takes a behavior mixin such as on("click", …) through mix like any element does, and the icons swap on the next render.

resources/components/copy-link.tsx
import type { Handle } from "remix/ui";

import { CheckIcon, CopyIcon } from "@sdxc/icons";
import { Button } from "@sdxc/ui";
import { clientEntry, on } from "remix/ui";

export type CopyLinkProps = { href: string; label: string };

export const CopyLink = clientEntry(
	"/resources/components/copy-link.tsx#CopyLink",
	function CopyLink(handle: Handle<CopyLinkProps>) {
		let copied = false;

		let copy = on<HTMLButtonElement>("click", async () => {
			await navigator.clipboard.writeText(handle.props.href);
			copied = true;
			handle.update();
		});

		return () => (
			<Button
				type="button"
				variant="ghost"
				color="neutral"
				size="sm"
				mix={[copy]}
			>
				{copied ? <CheckIcon size={16} /> : <CopyIcon size={16} />}
				{handle.props.label}
			</Button>
		);
	},
);

Everything else about islands, from the client bootstrap that loads them to which props can cross to the browser, is remix/ui's own; see Remix's hydration guide. Link the client script only from pages that render an island, and every other page ships no script at all.

Where to go next