Set up the UI system
Install Tailwind and shadcn/ui so copied primitives, tokens, variants and feature boundaries have one durable home.
Use this once when establishing a frontend. The Tailwind and shadcn/ui standard owns the choices; this page turns them into a working repository shape.
1. Create the shared boundary
Keep copied shadcn primitives outside features. Promote an application component only after it passes the design-system promotion test.
src/
ui/
components/ copied and owned shadcn primitives
patterns/ promoted application-level compositions
features/
reservations/ feature-owned UI and behaviour
lib/
utils.ts class-name composition2. Configure shadcn ownership
Turn CSS variables on, choose one neutral base colour, and route copied components into the shared boundary.
{
"style": "new-york",
"tailwind": {
"cssVariables": true,
"baseColor": "neutral"
},
"aliases": {
"components": "@/ui/components",
"utils": "@/lib/utils"
}
}Treat each added component as owned source from the moment it lands. Review it, version it and record local modifications before using the CLI on it again.
3. Put the theme in CSS
Tailwind v4 keeps tokens in the global stylesheet. Use semantic tokens instead of distributing raw colours, spacing and radii through features.
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--radius-md: var(--radius);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--radius: 0.625rem;
}Dark mode changes the custom-property values. Components continue to consume the same semantic tokens.
Colour is reserved for status
The default state of an interface is neutral. Colour means something happened.
Status tokens come in pairs — -strong for the text and the dot, -bg for the
chip's fill — and the pair holds 4.5:1 against each other:
--status-approved: #2f7a41;
--status-approved-bg: #e8f3ea;
--status-pending: #9a5b0c;
--status-pending-bg: #fdf1e3;Never borrow the action colour for a status. A green dot reusing the button green says button as loudly as it says in force, and the two have different reasons to change. A brand refresh should not repaint your states.
Colour never carries meaning alone — a dot always travels with a word. See registers.
One accent per surface
A colour beyond status is legitimate — a brand accent, an attention colour — under one rule: at most one instance per surface, and only on the element that genuinely asks to be looked at.
Worked example, six schematic previews of six screens: the uncovered-territory warning takes the accent, so does the draft awaiting publication, the failed delivery in a log, and the dependants count that would refuse a deletion. The accounts list and the audit journal take none — neither screen has anything that alerts.
Adding one for visual balance is what teaches the reader to stop believing it. The restraint is the pattern; the colour is incidental.
An accent that fails contrast is a background, not a text colour. Measure it,
write the ratio in the token's comment, and give it a -strong variant for the
cases where it must be read.
Write the reason beside the value
A hex with no comment is a hex somebody will change. The comment is where the contrast measurement, the rejected alternative and the constraint live.
/* The application bar — the same green, dropped almost to ink (14.73:1 on white).
Dropped rather than replaced with a grey: a neutral bar above a green action
gives one screen two identities. */
--surface-bar: #021207;Measures are tokens too — content widths, page padding, a rail's two widths. A layout value written inline is a value that will differ by four pixels on the next screen.
4. Make variants explicit
Use CVA when more than two boolean props drive classes. Keep complete literal class names so Tailwind can detect them.
import { cva, type VariantProps } from "class-variance-authority";
const statusBadge = cva("inline-flex rounded-full px-2 py-1 text-xs", {
variants: {
tone: {
neutral: "bg-muted text-muted-foreground",
success: "bg-success/15 text-success",
danger: "bg-destructive/15 text-destructive",
},
},
defaultVariants: { tone: "neutral" },
});
type StatusBadgeProps = VariantProps<typeof statusBadge> & {
children: React.ReactNode;
};
export function StatusBadge({ children, tone }: StatusBadgeProps) {
return <span className={statusBadge({ tone })}>{children}</span>;
}5. Enforce the boundary
Fail CI when a feature imports into the design system, when a banned styling technology appears, or when a second icon library enters the dependency graph. The checking scripts are the reference for executable policy.