Architect
How-to guides
How-toNormative

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 composition

2. Configure shadcn ownership

Turn CSS variables on, choose one neutral base colour, and route copied components into the shared boundary.

components.json
{
  "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.

src/app/globals.css
@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.

src/ui/components/status-badge.tsx
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.

On this page