Skip to content
design-system-mcp

Rules

Eight rules. Every finding says what to write instead.

check_ui for the agent, and check for CI and the Claude Code hook, run the same rules against the design system read from your source. This page is generated from version 0.3.1: each example below went through the real linter on the demo design system when the site was built. Try your own in the playground.

no-hardcoded-color

error by default

Colors must come from design tokens: no hex/rgb/oklch literals, arbitrary Tailwind colors or default-palette classes.

Catches
Hex/rgb/hsl/oklch literals in classes (bg-[#ef4444], [color:red]), styles and color attributes (<svg fill>; on components, only hex and color functions in props without known values); Tailwind default-palette classes (bg-gray-100)
Suggests
The nearest token of the same hue whose role fits the utility (text-gray-500 → text-muted-foreground), or the variant that already applies it
Why it matters
A hex value is a copy of a token that stops following it: when the theme changes, or in dark mode, the copy stays behind. Agents write them whenever a prompt names a color ("a red alert", "a green label"), and in the benchmark they were most of what the agents got wrong.
What the agent wrote
<div className="rounded-md bg-gray-100 p-3 text-[#737373]">  Only owners can delete a workspace.</div>
After the fixthe rule’s own fixes
<div className="rounded-md bg-muted p-3 text-muted-foreground">  Only owners can delete a workspace.</div>
check_ui
  1. error1:28fix included

    bg-gray-100 is Tailwind's default palette, not a design-system color. Matches token muted (ΔE 0.004, same value as secondary, accent) → bg-muted.

  2. error1:44fix included

    Hardcoded color text-[#737373]. Matches token muted-foreground (exact match) → text-muted-foreground.

Option. Colors to accept anyway, as written in the class or the style: ["error", { "allow": ["#fff"] }].

no-hardcoded-spacing

warning by default

Padding, margin and gap must use the spacing scale, not arbitrary px/rem values.

Catches
Arbitrary padding, margin and gap (p-[13px], style={{ marginTop: 6 }})
Suggests
Nearest step on the spacing scale, keeping the sign; with Tailwind v4, any whole or half step of --spacing (p-3, p-4.5, p-13) or p-px
Why it matters
Off-scale padding and gaps make layouts drift a pixel at a time, and an arbitrary value hides which step was meant. When the value is on the scale, the fix is just its name. A warning by default, because a deliberate one-off is sometimes right.
What the agent wrote
<div className="flex items-center gap-[6px] px-[18px] py-[13px]">  <Badge>Beta</Badge></div>
After the fixthe rule’s own fixes
<div className="flex items-center gap-1.5 px-4.5 py-3">  <Badge>Beta</Badge></div>
check_ui
  1. warning1:35fix included

    gap-[6px] is 6px, which is on the spacing scale: use gap-1.5.

  2. warning1:45fix included

    px-[18px] is 18px, which is on the spacing scale: use px-4.5.

  3. warning1:55fix included

    Hardcoded spacing py-[13px] (13px) is off the scale. Nearest: py-3 (12px).

Option. Values to accept anyway, as the class or the length: ["warn", { "allow": ["px-[18px]"] }].

no-hardcoded-radius

warning by default

Border radius must use radius tokens, not arbitrary values.

Catches
Arbitrary radius (rounded-[7px], borderRadius: 14)
Suggests
Nearest radius token (rounded-sm), including Tailwind's default keys; rounded-full (or a pill token) for values far above the scale (rounded-[999px])
Why it matters
Corners are part of the visual language. An arbitrary radius looks almost right next to the components and never quite matches them. A warning by default.
What the agent wrote
<div className="rounded-[7px] border p-4">  <Badge className="rounded-[999px]">New</Badge></div>
After the fixthe rule’s own fixes
<div className="rounded-sm border p-4">  <Badge className="rounded-full">New</Badge></div>
check_ui
  1. warning1:17fix included

    Hardcoded radius rounded-[7px] (7px) is off the scale. Nearest: rounded-sm (6px).

  2. warning2:21fix included

    rounded-[999px] (999px) is far above the radius scale, so it reads as fully rounded: use rounded-full.

Option. Values to accept anyway, as the class or the length.

Use the design-system component instead of the native element it wraps (<Button> over <button>).

Catches
Native elements a component wraps (<button>, <input>, <dialog>), inferred from each component's props and markup
Suggests
The root component and its import, preferring one that renders the element (NativeSelect over Select); the rename is auto-fixed when it renders that element, takes its attributes or is mapped in elements
Why it matters
A native <button> skips what the design system's Button carries: variants, focus styles, disabled states, and every fix the team made since. Agents write native elements with hand-rolled classes because that is what most code they learned from looks like.
What the agent wrote
<form className="flex gap-2">  <input type="email" placeholder="you@example.com" />  <button className="rounded-md bg-primary px-3 text-sm text-primary-foreground">    Invite  </button></form>
After the fixthe rule’s own fixes
<form className="flex gap-2">  <Input type="email" placeholder="you@example.com" />  <Button className="rounded-md bg-primary px-3 text-sm text-primary-foreground">    Invite  </Button></form>
check_ui
  1. error2:4fix included

    Native <input> where the design system has <Input>. Use <Input> (import { Input } from "@/components/ui/input").

  2. error3:4fix included

    Native <button> where the design system has <Button>. Use <Button> (import { Button } from "@/components/ui/button"); its variants replace the custom classes.

Option. Native elements to accept anyway: ["error", { "allow": ["a"] }].

no-unknown-component

error by default

Components must exist in the design system: no invented components or dot-notation members.

Catches
Invented components, dot-notation members that do not exist (<Card.Header>), typos
Suggests
The flat part (<CardHeader>) or closest name, auto-fixed only when that component is imported
Why it matters
Agents mix up conventions between libraries: <Card.Header> where the system exports flat parts, or a component that only exists in another kit. TypeScript reports it once the file compiles in the project; check_ui reports it in a snippet the agent has not saved yet, and names the part to use.
What the agent wrote
<Card>  <Card.Header>    <Card.Title>Billing</Card.Title>  </Card.Header></Card>
After the fixthe rule’s own fixes
<Card>  <CardHeader>    <CardTitle>Billing</CardTitle>  </CardHeader></Card>
check_ui
  1. error2:4fix included

    <Card.Header> does not exist: Card is composed from flat parts. Use <CardHeader> (import { CardHeader } from "@/components/ui/card").

  2. error3:6fix included

    <Card.Title> does not exist: Card is composed from flat parts. Use <CardTitle> (import { CardTitle } from "@/components/ui/card").

no-unknown-prop

error by default

Props must exist on the component (own props or the HTML attributes it forwards).

Catches
Props a component does not accept, own or inherited (tone, isDisabled)
Suggests
Closest prop, with cross-library synonyms (tone → variant) and equivalents in either direction (checked ↔ isSelected, open ↔ isOpen); asChild on a Base UI component (or render on a Radix one) gets how that library composes
Why it matters
Props from other libraries (tone, isDisabled, isOpen) do nothing or break the build. The rule suggests the prop this system uses, with its allowed values, and explains how Radix (asChild) and Base UI (render) compose when an agent mixes them up.
What the agent wrote
<div className="grid gap-2">  <Badge tone="success">Active</Badge>  <Input isDisabled placeholder="Workspace name" /></div>
After the fixthe rule’s own fixes
<div className="grid gap-2">  <Badge variant="success">Active</Badge>  <Input disabled placeholder="Workspace name" /></div>
check_ui
  1. error2:10fix included

    <Badge> has no prop "tone". Did you mean "variant" ("default" | "secondary" | "destructive" | "success" | "outline")?

  2. error3:10fix included

    <Input> has no prop "isDisabled". Did you mean "disabled"?

no-unknown-variant

error by default

Variant props (cva variants and string-literal unions) only accept their declared values.

Catches
Values outside a cva variant or literal union (variant="danger")
Suggests
Allowed values and a synonym match (danger → destructive, small → sm)
Why it matters
variant="danger" on a Button that only knows destructive renders without that variant's styles, so the mistake looks like a working button. The rule lists the allowed values and maps synonyms across libraries (danger → destructive, small → sm).
What the agent wrote
<Button variant="danger" size="small">  Delete workspace</Button>
After the fixthe rule’s own fixes
<Button variant="destructive" size="sm">  Delete workspace</Button>
check_ui
  1. error1:17fix included

    "danger" is not a valid variant for <Button>. Allowed: default, destructive, outline, secondary, ghost, link. Did you mean "destructive"?

  2. error1:31fix included

    "small" is not a valid size for <Button>. Allowed: default, sm, lg, icon. Did you mean "sm"?

Buttons whose only content is an icon need an aria-label (or visually hidden text).

Catches
Buttons whose only content is an icon, with no aria-label, aria-labelledby, title or visually hidden text; a button passed as Base UI's render is judged by its host's children, a render-prop child ({() => <Trash2 />}) by what it returns, and hidden buttons are skipped
Suggests
aria-label, guessed from the icon (Trash2 → "Delete")
Why it matters
A button with only an icon is announced as just "button" to screen-reader users. It is the most common accessibility slip in real apps: check found 11 in vercel/ai-chatbot and 105 in midday's dashboard. The fix guesses the label from the icon (Trash2 → "Delete").
What the agent wrote
<Button variant="ghost" size="icon">  <Trash2 /></Button>
After the fixthe rule’s own fixes
<Button aria-label="Delete" variant="ghost" size="icon">  <Trash2 /></Button>
check_ui
  1. error1:2fix included

    Icon-only <Button> has no accessible name. Add aria-label="Delete" describing the action, or visually hidden text.

Configuring the rules

Set a rule to "off", "warn" or "error", or give it values to accept with [severity, { "allow": [...] }], in design-system-mcp.config.json. Rules that need tokens skip themselves when the design system has none of that kind, and code that does not parse is reported under syntax. The config file covers the rest.

{  "rules": {    "no-hardcoded-spacing": "error",    "no-hardcoded-color": ["error", { "allow": ["#fff"] }],    "icon-button-accessible-name": "off"  }}

How fixes are chosen

Color matches under ΔE 0.02 count as the same color; under 0.1 the fix is offered; beyond that the message names the nearest token but leaves the choice to the agent. A fix is only offered for a token of the same hue, or a gray for a gray, so a pale yellow is never swapped for a light gray that happens to be close. Among the tokens that qualify, the one made for the utility wins: foreground and muted-foreground for text-*, fill-* and stroke-*; surfaces such as muted for bg-*; border, input and ring for border-* and ring-*. sidebar-* and chart-* tokens are only suggested in a sidebar or a chart (by file, enclosing component or classes), and dark: classes are compared with dark-mode values. Likewise, a spacing or radius step that is off by more than half the value (and more than 4px) is suggested but not auto-fixed. Rules that need tokens are skipped when the design system defines none of that category; a stylesheet that imports tailwindcss brings Tailwind's default spacing unit and radius scale, unless the theme resets that namespace (--spacing-*: initial, --radius-*: initial), in which case only the project's own steps are suggested. Syntax errors are reported as syntax, and so is code nested too deeply for TypeScript's parser (thousands of levels), which is then not checked.