# Claude Code Design System: Make 50 Screens Match

> Set up a Claude Code design system: tokens, size tables and component rules the agent reads, plus the checks that prove 50 generated screens still match.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Building UI with AI agents](https://wingo-ui.com/blog/category/ai-agents)
- Reading time: 12 min
- Canonical: https://wingo-ui.com/blog/claude-code-design-system

## TL;DR

A Claude Code design system puts exact values where the agent cannot miss them: color tokens in CSS, a DESIGN.md with size, radius and type tables, and components whose props and provider defaults already encode those tables. A short CLAUDE.md and a project skill point Claude Code at the rules. A PostToolUse hook that greps each edited file and a script that counts the buttons on every route catch the drift that gets through.

You asked Claude Code for a settings page on Monday, a billing page on Tuesday and an onboarding flow on Wednesday. Each one looks fine on its own. Side by side, the buttons are three different heights, one page has 8px corners and another 12px, and the billing page found a blue you never picked. A Claude Code design system fixes this by giving the agent the same exact values in every session: tokens, size tables, component rules, and a check that tells it when it drifted.

This tutorial builds that setup for a Next.js and Tailwind CSS v4 app. The examples use Wingo UI's [Button](https://wingo-ui.com/components/button), [UIProvider](https://wingo-ui.com/components/ui-config) and [Tones](https://wingo-ui.com/components/tones), because we run the same setup in our own repository, where one design file governs a 352-item catalog (as of October 2026). The layout works with any component library. More guides on agents and UI are in [Building UI with AI agents](https://wingo-ui.com/blog/category/ai-agents).

## Why do screens from Claude Code drift apart?

Every session starts without memory of the last one. When a prompt says "a settings page" and nothing in context says how tall a button is, Claude Code picks a reasonable value, and the next session picks another reasonable value. Each choice is defensible. Fifty of them are a product that looks assembled from five kits.

Prose rules help less than people expect. "Keep spacing consistent" names no number, so the agent has nothing to copy. A rule like "buttons are 32, 40 or 48px tall, `md` by default" gives it one.

Memory files do not close the gap on their own either. Anthropic's [Claude Code memory docs](https://code.claude.com/docs/en/memory) (October 2026) say Claude treats CLAUDE.md as context, that specific and concise instructions are followed more consistently, and that a hook is the tool for anything that must happen regardless of what Claude decides. That is why this setup puts the values in code and adds checks.

Many people install the Claude Code frontend design skill first. It is an official plugin in the [`anthropics/claude-code` repository](https://github.com/anthropics/claude-code/tree/main/plugins/frontend-design) (version 1.1.0 as of October 2026, by Prithvi Rajasekaran and Alexander Bricken), and its description calls it guidance for "distinctive, intentional visual design". Its instructions ask Claude to act as the design lead of a studio, pick a palette and typefaces specific to each brief, and write a plan with four to six named colors before any code. That is the right brief for a new landing page with its own identity. It is the wrong brief for the 37th screen of an app that already has one, and the skill does not cover working inside existing tokens or components. For product screens, your rules have to come from your own files.

## What goes into a Claude Code design system?

Five pieces, each in the place where it is hardest to ignore:

1. **Tokens in CSS.** Colors, elevation and layers as CSS variables, mapped to Tailwind classes, so the agent writes `bg-surface` and both themes work.
2. **A DESIGN.md with tables.** Sizes, radius per size, type scale and spacing as numbers, plus the prop names every component uses.
3. **Components that carry the tables.** The size and radius maps live inside the component, so `<Button size="sm">` is right without the agent knowing any pixel value.
4. **Defaults in code.** App-wide choices set once in a provider, so the shortest JSX the agent can write is also the correct JSX.
5. **Checks.** A hook after every edit and a script across every route, so drift comes back to the agent as an error.

Here is the project layout we will end up with:

```text
your-app/
├── CLAUDE.md                          # @AGENTS.md, nothing else
├── AGENTS.md                          # short: commands, where things live, "read docs/DESIGN.md"
├── docs/DESIGN.md                     # the tables
├── app/globals.css                    # the tokens
├── app/layout.tsx                     # UIProvider defaults
├── .claude/skills/product-ui/SKILL.md # loads for any UI task
├── .claude/hooks/check-ui.sh          # runs after every Edit or Write
├── .claude/settings.json              # registers the hook
└── scripts/ui-inventory.ts            # buttons per route, across the app
```

## Step 1: How do you give Claude Code your design tokens?

Put them in CSS, where the agent reads them and the browser enforces them. In Tailwind CSS v4 that means plain CSS variables for each theme and an `@theme inline` block that turns them into utilities. A shortened version of ours, with most tokens and some shadow layers left out:

```css
:root {
  --background: oklch(1 0 0);
  --surface: #ffffff;
  --brand: #d63f00;
  --elevation-raised: 0 0 0 1px rgb(0 0 0 / 0.07), 0 1px 2px rgb(0 0 0 / 0.05);
  --z-popover: 1400;
}

.dark {
  --background: oklch(0 0 0);
  --surface: #1a1a1a;
  --elevation-raised: inset 0 1px 0 0 rgb(255 255 255 / 0.06), 0 0 0 1px rgb(255 255 255 / 0.08);
}

@theme inline {
  --color-background: var(--background);
  --color-surface: var(--surface);
  --color-brand: var(--brand);
  --shadow-raised: var(--elevation-raised);
  --z-index-popover: var(--z-popover);
}
```

Now `bg-surface shadow-raised` is a raised card in both themes and `z-popover` is a layer with a name. The semantic names matter more than the values: an agent that writes `bg-surface` cannot pick the wrong gray. The full approach, with dark mode and contrast checks, is in [Tailwind v4 design tokens](https://wingo-ui.com/blog/tailwind-v4-design-tokens).

## Step 2: What should a Claude Code DESIGN.md contain?

Numbers in tables, and the reason behind each rule that looks arbitrary. Our `docs/DESIGN.md` opens by saying that the numbers are decisions, and that an author who needs something the file does not cover should copy the closest pattern and say so. That one instruction stops most invented values.

The size table is the part that makes screens line up, because a button, an input and a select only match when all three use it:

```md
## Sizes

`size="sm" | "md" | "lg"`, default `md`. Every control with a height uses this table.

|                  | sm                      | md                         | lg             |
| ---------------- | ----------------------- | -------------------------- | -------------- |
| Height           | 32px `h-8`              | 40px `h-10`                | 48px `h-12`    |
| On touch screens | 32px + 44px hit area    | 44px `pointer-coarse:h-11` | 48px           |
| Padding (button) | `px-3`                  | `px-4`                     | `px-5`         |
| Font             | `text-[13px]`           | `text-sm`                  | `text-[15px]`  |
| Icon             | `size-3.5`              | `size-4`                   | `size-[18px]`  |

Radius per size (`radius` prop, default `md`), for sizes sm / md / lg: `md` 8 / 10 / 12px, `lg` 10 / 14 / 16px, `full` a pill.

## Rules

- Every `tone` defaults to `neutral`. `tone="brand"` is opt-in, at most once per screen, for its main action.
- One solid button per view. Secondary actions use `soft` or `outline`.
- Colored text is `text-<tone>-soft-foreground`, never `text-<tone>`: fills are tuned for fills.
```

Ours is 614 lines, so the AGENTS.md links it ("Read docs/DESIGN.md before building anything") instead of importing it into every session. How CLAUDE.md, AGENTS.md and imports load is covered in [AGENTS.md vs CLAUDE.md](https://wingo-ui.com/blog/agents-md-vs-claude-md-react).

If you would rather keep tokens in a standard shape, the `google-labs-code` organization publishes an open [DESIGN.md format](https://github.com/google-labs-code/design.md): YAML front matter with colors, typography, rounded and spacing tokens, then Markdown sections for the rationale. Its CLI (`npx @google/design.md lint DESIGN.md`) reports broken token references and WCAG contrast problems as JSON and exits with code 1 on errors. As of October 2026 the format is at version alpha, so expect field names to change.

## Step 3: How do components keep Claude Code UI design consistent?

By owning the tables, so the agent only picks a name. Claude Code UI design gets consistent fastest when `size`, `variant`, `tone` and `radius` are the only decisions left. In our [Button](https://wingo-ui.com/components/button), the size table is a map in the file:

```tsx
// 32 / 40 / 48px. on a coarse pointer md grows to 44px and sm keeps its look but gets a 44px hit area
const SIZES: Record<ButtonSize, string> = {
  sm: "h-8 gap-1.5 px-3 text-[13px] pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-1.5",
  md: "h-10 gap-2 px-4 text-sm pointer-coarse:h-11",
  lg: "h-12 gap-2 px-5 text-[15px]",
  "icon-sm": "size-8 pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5",
  icon: "size-10 pointer-coarse:size-11",
  "icon-lg": "size-12",
};
```

Colors go through one more layer. Every component with a `tone` prop paints with five CSS variables (`--tone`, `--tone-foreground`, `--tone-soft`, `--tone-soft-foreground`, `--tone-border`) that [Tones](https://wingo-ui.com/components/tones) sets from the design tokens, or from any CSS color through `colorVars`. A rule in DESIGN.md can then say "tone" and never name a color.

> Live demo (Tones): Six tones and one custom color, each painted as a solid fill, a soft tint and an outline from the same five CSS variables. Switch the site theme to see every pair stay readable. Try it at [Tones](https://wingo-ui.com/components/tones) and install it with `npx wingo-ui@latest add tones`.

App-wide choices go in the root layout. Every Wingo UI component reads its defaults through `useDefaults` under its `data-slot` name, and explicit props still win:

```tsx
// app/layout.tsx
import type { ReactNode } from "react";
import { UIProvider } from "@/lib/ui-config";
import "./globals.css";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <UIProvider locale="en-US" defaults={{ button: { radius: "full" } }}>
          {children}
        </UIProvider>
      </body>
    </html>
  );
}
```

After that, `<Button>Save changes</Button>` on any of the 50 screens is pill-shaped, and the agent never had to remember it.

> Live demo (UIProvider): The top button has no provider. The one below sits in a provider with radius full and tone brand, and the pair at the bottom adds a nested provider with size sm that keeps the outer radius and tone. Try it at [UIProvider](https://wingo-ui.com/components/ui-config) and install it with `npx wingo-ui@latest add ui-config`.

## Step 4: How do you load the rules for every UI task?

With a project skill. Claude Code reads skills from `.claude/skills/<name>/SKILL.md`. Only the `description` sits in context all the time; the body loads when Claude decides the task matches, or when you type `/product-ui`. Anthropic's [skills docs](https://code.claude.com/docs/en/skills) suggest keeping SKILL.md under 500 lines and moving reference material into separate files, which suits a design system: the skill is the checklist, DESIGN.md is the reference.

```md
---
name: product-ui
description: Rules for building or changing any screen, page, form, dialog or component in this app. Use before writing UI code and before reviewing a screen.
---

# Product UI

This app has one design system. Match it; never invent a value.

1. Read the sections of docs/DESIGN.md you need: tokens, sizes, radius, type and spacing, props.
2. Build from components/ui. Configure with props (size, variant, tone, radius, classNames) before adding classes.
3. Do not repeat app-wide defaults (they live in UIProvider in app/layout.tsx). Pass a prop only where one instance differs.
4. Semantic color classes only: bg-background, bg-surface, bg-card, bg-popover, bg-foreground/5, text-foreground, text-muted-foreground, border-border.
5. One solid button per view. tone="brand" at most once per screen, for its main action.
6. If DESIGN.md does not cover something, copy the closest existing pattern and say which one in your summary.
7. Before you finish: run `npm run ui:inventory` and fix every finding.
```

If you use Wingo UI, `npx wingo-ui@latest agents install claude` writes `.claude/skills/wingo-ui/SKILL.md` with the library's version of these rules, plus `.mcp.json` and a marked block in CLAUDE.md, and the MCP server's `get_design_rules` and `get_theme_tokens` tools return the current rules and CSS variables. The [coding agents docs](https://wingo-ui.com/docs/agents) list every file it writes.

## Step 5: How do you check that Claude Code followed the design system?

Two checks: one per file, right after each edit, and one across the app, before you call the work done. Consistent UI with AI agents depends on this step, because a rule the agent skips comes back to it as an error in the same session instead of in your review a week later.

### Check every edited file with a hook

A `PostToolUse` hook runs after each `Edit` or `Write`. It receives the tool call as JSON on stdin, with the path in `tool_input.file_path`. The edit has already happened, so exit code 2 cannot block it, but the [hooks reference](https://code.claude.com/docs/en/hooks) says Claude Code then shows the hook's stderr to Claude, which fixes the file. Exit code 0 hides stderr from Claude. Register it in `.claude/settings.json` (the `args` array spawns the script directly, without a shell, which the docs recommend for paths with `${CLAUDE_PROJECT_DIR}`):

```json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-ui.sh",
            "args": []
          }
        ]
      }
    ]
  }
}
```

The script greps the edited file for values outside the system. The pattern is adapted from the drift check at the end of our own DESIGN.md, plus hex and palette colors:

```sh
#!/usr/bin/env bash
# .claude/hooks/check-ui.sh: flags values outside docs/DESIGN.md in the file Claude just edited
file=$(jq -r '.tool_input.file_path // empty')
case "$file" in *.tsx) ;; *) exit 0 ;; esac
# library source keeps hex colors as overridable prop defaults
case "$file" in */components/ui/*|*/components/blocks/*) exit 0 ;; esac

colors='#[0-9a-fA-F]{6}|-(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-[0-9]{2,3}'
system='rounded-(sm|md|lg|xl)([^-]|$)|bg-(accent|secondary)([^-]|$)|z-[0-9]|text-(destructive|brand|success|warning|info)([^-]|$)'

if hits=$(grep -nE "$colors|$system" "$file"); then
  echo "Design system drift in $file:" >&2
  echo "$hits" >&2
  echo "Use the tokens, sizes and radius tables in docs/DESIGN.md, or a component prop." >&2
  exit 2
fi
exit 0
```

Make it executable with `chmod +x .claude/hooks/check-ui.sh`; it needs `jq`. Each pattern maps to a rule: palette classes bypass the tokens, `rounded-lg` and friends are off the radius table, `z-50` skips the named layers, and `text-destructive` uses a fill color as text, which falls below WCAG AA on a dark card.

### Inventory every route

The grep cannot see a page with four solid buttons, or a screen where every action is orange. The rendered DOM can. Our Button writes its decisions into attributes (`data-slot="button"`, `data-variant`, `data-tone`, `data-size`), so a short Playwright script can count them on every route:

```ts
// scripts/ui-inventory.ts: npx tsx scripts/ui-inventory.ts (with the dev server running)
import { chromium } from "playwright";

const BASE = process.env.BASE_URL ?? "http://localhost:3000";
const ROUTES = ["/", "/dashboard", "/settings", "/billing", "/onboarding"];

async function main() {
  const browser = await chromium.launch();
  const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
  const findings: string[] = [];

  for (const route of ROUTES) {
    await page.goto(BASE + route, { waitUntil: "networkidle" });
    const buttons = await page.$$eval('[data-slot="button"]', (els) =>
      els
        .filter((el) => el.getClientRects().length > 0)
        .map((el) => ({
          variant: el.getAttribute("data-variant") ?? "",
          tone: el.getAttribute("data-tone") ?? "",
          size: el.getAttribute("data-size") ?? "",
        })),
    );

    const solid = buttons.filter((b) => b.variant === "solid").length;
    const brand = buttons.filter((b) => b.tone === "brand").length;
    const custom = buttons.filter((b) => b.tone === "custom").length;
    const sizes = [...new Set(buttons.map((b) => b.size))].join(", ");

    console.log(`${route}: ${buttons.length} buttons, ${solid} solid, ${brand} brand, sizes ${sizes || "none"}`);
    if (solid > 1) findings.push(`${route}: ${solid} solid buttons, the rule is one per view`);
    if (brand > 1) findings.push(`${route}: ${brand} brand buttons, brand is for the one main action`);
    if (custom > 0) findings.push(`${route}: ${custom} buttons with a custom color prop`);
  }

  await browser.close();
  if (findings.length > 0) {
    console.error(findings.join("\n"));
    process.exit(1);
  }
}

main();
```

Install it with `npm i -D playwright tsx` and `npx playwright install chromium`, and add `"ui:inventory": "tsx scripts/ui-inventory.ts"` to `package.json` so the skill can name it. The one-solid rule is per view, so a page with an open dialog can legitimately fail it; treat that line as a prompt to look, and narrow `ROUTES` or the selector when a page earns an exception. Every Wingo UI component carries a `data-slot`. Select, Badge, Toggle and Tabs also write `data-size` and `data-variant`, so the script extends to them by changing the selector. Check the component file before you count a new one: Input, for example, does not write `data-size`.

Then look at the screens yourself. Screenshot each route at 390px and 1440px wide, in light and dark mode, and put them side by side. A table of numbers will not tell you that two empty states use different icons.

## Where should you start?

Start with the size table. Write it into `docs/DESIGN.md`, point a project skill at it, and add the hook. From the next screen on, Claude Code gets an error for every off-table radius, raw z-index or palette color it writes. Then move the defaults you keep repeating into the provider, and run the inventory before each merge.

The [Button](https://wingo-ui.com/components/button), the [UIProvider](https://wingo-ui.com/components/ui-config) and [Tones](https://wingo-ui.com/components/tones) used here are free:

```sh
npx wingo-ui@latest add button ui-config tones
```

For the server side of this setup, where the agent looks up real props instead of guessing them, read the [component library MCP guide](https://wingo-ui.com/blog/component-library-mcp-guide) and the [MCP docs](https://wingo-ui.com/docs/mcp).

## Components in this post

- [Button](https://wingo-ui.com/components/button) (Free): The React button component every screen starts with: five variants, six tones, all sizes, and a loading state that never jumps. Install: `npx wingo-ui@latest add button`
- [UIProvider](https://wingo-ui.com/components/ui-config) (Free): A React config provider for Wingo UI: app-wide default props for every component, the Intl locale and a reduced-motion policy. Install: `npx wingo-ui@latest add ui-config`
- [Tones](https://wingo-ui.com/components/tones) (Free): One way to color a React component: a tone or any CSS color becomes five CSS variables every variant paints with. Install: `npx wingo-ui@latest add tones`

## FAQ

### How do I make Claude Code follow my design system?

Put the values where Claude Code cannot miss them: color tokens in your CSS, exact tables in a DESIGN.md that a project skill points to, and app-wide defaults in code through a provider. Then add a PostToolUse hook that checks each edited file, so drift is reported back to Claude in the same session.

### Should I use the Claude Code frontend design skill for product screens?

Use it for new pages that need their own look. Its instructions push for a distinctive palette and typography per brief, which is the opposite of what screen 37 of an existing product needs, so product work should follow your own DESIGN.md.

### What should a DESIGN.md for Claude Code contain?

Exact tables an agent can copy: surfaces and text levels, the size table (height, padding, font, icon per size), radius per size, the type scale, spacing steps and the prop names your components use. Rules without numbers, like "keep spacing consistent", give the agent nothing to copy.

### How do I check that AI-generated screens are consistent?

Run a grep for values outside the system (hex colors, palette classes, off-table radii, raw z-index) on every edited file, and a browser script that counts each route's buttons by variant, tone and size. Then look at screenshots at 390px and 1440px wide in both themes.

### Is a CLAUDE.md enough on its own?

No. Anthropic's memory docs say Claude treats CLAUDE.md as context and point to hooks for anything that must happen regardless of what Claude decides. Defaults in code and checks that run after each edit apply whatever the model chooses.

---

Source: https://wingo-ui.com/blog/claude-code-design-system
