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, in two source-only workspace packages: the UI package holds every component, flat, and the theme package holds the tokens. Promote an application component only after it passes the design-system promotion test.

shared/web/
  ui/
    src/button.tsx      one file per component, primitives and promoted compositions alike
    src/empty-state.tsx
    src/lib/utils.ts    class-name composition (cn)
    COMPONENTS.md       one row per component file — tested
    package.json
  theme/
    src/styles.css      tokens, fonts, Tailwind plugins
    package.json
web/
  product/src/features/<feature>/components/   feature-owned UI

Adapted from a production repository (single-app variant: the same flat folder at src/ui/).

2. Give each component its own export

The UI package exports a pattern, so the import names the file and there is no barrel to maintain:

shared/web/ui/package.json (excerpt)
{
  "name": "@acme/ui",
  "private": true,
  "type": "module",
  "exports": {
    "./*": "./src/*.tsx",
    "./lib/*": "./src/lib/*.ts",
    "./hooks/*": "./src/hooks/*.ts"
  },
  "dependencies": {
    "@phosphor-icons/react": "^2.1.10",
    "class-variance-authority": "^0.7.1",
    "radix-ui": "^1.6.7",
    "tailwind-merge": "^3.3.1"
  }
}

A screen then imports @acme/ui/button, @acme/ui/dialog. Copy a shadcn component into src/ by hand, adjust its imports to the package, add its row to COMPONENTS.md, and treat it as owned source from that moment. There is no components.json: the CLI has no per-app folder to write into.

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.

shared/web/theme/src/styles.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.

Each app imports the theme, then lists where its classes come from — the build runs from the app's directory, which holds only the shell:

web/app/src/styles.css
@import "@acme/theme/styles.css";

@source "../../product/src";
@source "../../../shared/web/*/src/**/*.{ts,tsx}";

A source that matches nothing is dropped silently; keep the test that every source matches a file.

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.

shared/web/ui/src/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. Add the catalog test and the design-system guard with an empty baseline on day one. The checking scripts are the reference for executable policy.

On this page