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 UIAdapted 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:
{
"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.
@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:
@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.
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.