Cursor Rules for a Design System: Add UI Without Drift

Updated 8 min read

TL;DR Cursor follows a design system when a scoped .mdc rule in .cursor/rules lists your token class names, file boundaries and a no-new-dependencies rule. Pair it with the real component source, a short spec with a 'done when' list, a diff check for raw colors and new packages, then tsc and next build.

On this page
  1. Why does Cursor keep breaking my design?
  2. How do I write Cursor rules for a design system?
  3. Rule, AGENTS.md or spec: which file holds what?
  4. Give it the exact component source and a short spec
  5. Ask for a small, one-file diff
  6. Can I check the diff for design drift automatically?
  7. Verify with tsc and next build
  8. What are the best practices for Cursor and UI work?
  9. FAQ
  10. How do I stop Cursor from breaking or overwriting older features?
  11. Why does Cursor keep changing my styles?
  12. Where do Cursor rules go?
  13. Do I still need to check the build if Cursor says it passed?

Cursor rules for a design system are what stop an agent from restyling your app every time you add a component. Before you ask for UI, give Cursor three things: a project rule in .cursor/rules that lists your tokens and component conventions, the exact source of the component you want (not a description of it), and a short spec that says where it goes, which files it may touch and how you'll check it. Then ask for a small diff and verify it with tsc and next build before you look at it in the browser.

Most of the "it restyled my whole page" stories come from asking for a component by name ("add a nice copy button") and letting the agent guess. Most guides on Cursor rules stop at a sample rule file. Below is the full loop I use in a Next.js App Router project with Tailwind v4: the rule, the spec, the component, and a small script that catches drift in the diff before you accept it. The rule file, the spec and the component are real files from a test project, and the component passes tsc --noEmit and next build.

Why does Cursor keep breaking my design?

When you ask for "a copy button", the model writes the copy button it has seen most often. That usually means hardcoded colors like bg-gray-100 or #3b82f6, a new icon library, a different border radius from yours, and sometimes an edit to globals.css or tailwind.config to make its version look right. Each change is small and reasonable on its own. Together they leave your app with two visual languages.

Three gaps cause it:

  1. The agent doesn't know your tokens unless something in its context lists them.
  2. It doesn't know which files are off limits.
  3. "Done" is vague, so it keeps going until the result looks finished to it.

A rule file fixes the first two for every request. A short spec fixes the third for this one.

Liquid Glass Tab Bar running live. A glass drop magnifies the tab under it, stretches between tabs and bursts into the player.

How do I write Cursor rules for a design system?

Cursor's project rules live in .cursor/rules as .mdc files, with frontmatter that controls when each rule is included. According to Cursor's rules docs, a plain .md file in that folder is ignored because it has no frontmatter, and if you'd rather use plain Markdown there is AGENTS.md instead.

The frontmatter has three fields: description, globs and alwaysApply. With alwaysApply: false and globs set, the rule is attached when a matching file is in context. With only a description, the agent decides whether it's relevant. With alwaysApply: true, it's included in every chat.

For UI components I scope the rule to the component folder:

---
description: Conventions for UI components in src/components/ui
globs: src/components/ui/**/*.tsx
alwaysApply: false
---

- One component per file, named export, kebab-case file name (copy-button.tsx exports CopyButton).
- Add "use client" only when the file uses state, effects, refs or browser APIs.
- Style with Tailwind classes that use our tokens: bg-background, text-foreground, border-border, bg-muted, outline-ring, rounded-[var(--radius)]. No hex colors, no new tokens.
- Merge classes with cn() from @/lib/utils. Accept a className prop and pass it last.
- Animations: Tailwind transitions only. Every animated element gets motion-reduce:transition-none.
- No new npm dependencies. If you think one is needed, stop and ask.
- Keep the change inside the files named in the request. Do not edit globals.css or other components.

A few choices in there are deliberate:

  • Tokens by class name, not by value. The agent doesn't need to know that muted is #f4f4f5. It needs to know that bg-muted exists and that hex colors are not allowed.
  • "use client" only when needed. Without this line, agents tend to either add it to everything or forget it. In the App Router a component that uses state and has no "use client" fails the build as soon as a server page imports it.
  • "Stop and ask" for dependencies. This turns a silent npm install into a question.
  • A file boundary. "Do not edit globals.css or other components" stops most of the drift.

The tokens it refers to are defined once, in globals.css, using Tailwind v4's @theme:

css
/* Design tokens used by the Cursor article's example component */
@theme {
  --color-border: #e4e4e7;
  --color-muted: #f4f4f5;
  --color-ring: #6366f1;
}

:root {
  --radius: 0.5rem;
}

bg-background and text-foreground come from the @theme inline block that create-next-app already generates. @theme turns each --color-* variable into utilities like bg-muted and border-border. The details are in Tailwind's theme variables docs.

One limit worth knowing: the Cursor docs say a file mentioned inside a rule (like @some-file.ts) is not inlined into the prompt. The agent reads it with its tools when it decides it needs to. Anything that must always be in context, like your token names, belongs in the rule body itself.

Rule, AGENTS.md or spec: which file holds what?

FileWhen the agent reads itWhat goes in it
.cursor/rules/*.mdcAlways, by glob, or when the agent judges it relevantToken class names, file boundaries, dependency rule
AGENTS.mdAs plain project instructions, no frontmatterShort, stable conventions for any agent, not only Cursor
PROMPT.md (your own spec)Only when you attach it to a requestThis component: path, page, off-limits files, "done when"

I keep the long-lived rules in the .mdc file and anything about one specific component in the spec, so the rule stays short enough to be read on every UI request.

Give it the exact component source and a short spec

Don't ask the agent to invent the component. Paste the source you want, from your own library, a registry or a component you bought, and add a short spec next to it. I keep the spec as a PROMPT.md file in the repo so I can reuse it:

# Add: CopyButton

Source: paste of copy-button.tsx below (do not rewrite it from scratch).

Put it at: src/components/ui/copy-button.tsx
Use it in: src/app/docs/page.tsx, next to each install command in <code>.

Rules:
- Keep "use client" at the top of copy-button.tsx. The page stays a server component.
- Map colors to our tokens (border-border, bg-background, text-foreground, bg-muted, outline-ring).
- No new dependencies. cn() already exists in @/lib/utils.
- Touch only those two files.

Done when:
- npx tsc --noEmit passes
- npx next build passes
- Clicking the button copies the text and shows "Copied" for 1.5 s

The spec answers the questions the agent would otherwise guess at: which path, which page, which tokens, what's off limits, and what "done" means. The "Done when" list matters most, because it gives the agent a finish line it can check itself instead of polishing until it looks finished.

Here is the component the spec refers to, as it ended up in the test project:

tsx
"use client";

import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";

type CopyButtonProps = {
  value: string;
  className?: string;
};

export function CopyButton({ value, className }: CopyButtonProps) {
  const [copied, setCopied] = useState(false);

  useEffect(() => {
    if (!copied) return;
    const t = setTimeout(() => setCopied(false), 1500);
    return () => clearTimeout(t);
  }, [copied]);

  async function copy() {
    await navigator.clipboard.writeText(value);
    setCopied(true);
  }

  return (
    <button
      type="button"
      onClick={copy}
      aria-label={copied ? "Copied" : "Copy to clipboard"}
      className={cn(
        "relative inline-flex h-9 items-center gap-2 rounded-[var(--radius)] border border-border bg-background px-3 text-sm text-foreground",
        "transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring",
        className
      )}
    >
      <span
        aria-hidden
        className={cn(
          "transition-transform duration-200 motion-reduce:transition-none",
          copied ? "scale-0" : "scale-100"
        )}
      >
        ⧉
      </span>
      <span
        aria-hidden
        className={cn(
          "absolute left-3 transition-transform duration-200 motion-reduce:transition-none",
          copied ? "scale-100" : "scale-0"
        )}
      >
        ✓
      </span>
      <span aria-live="polite">{copied ? "Copied" : "Copy"}</span>
    </button>
  );
}

It uses only token classes, merges className last through cn(), has motion-reduce: on both animated icons, and announces "Copied" through a polite live region. The cn() helper is deliberately tiny, so the example needs no extra packages:

ts
export function cn(...classes: Array<string | false | null | undefined>) {
  return classes.filter(Boolean).join(" ");
}

If you already use shadcn/ui, your cn() comes from clsx and tailwind-merge, and the rule should point at that one.

Ask for a small, one-file diff

With the rule and the spec in place, the request itself can be short. I attach PROMPT.md and the component source in chat and write something like: "Follow PROMPT.md. Show me the diff before applying. Touch only the two files it names."

Then I check the diff for the usual suspects before accepting it:

  • any hex color or gray-/slate- class that isn't one of the tokens
  • a new import from a package that isn't already in package.json
  • "use client" added to page.tsx
  • edits to files the spec didn't name

If one of those shows up, I reject the change and point at the rule line it broke. Correcting it in chat ("you used bg-gray-100; the rule says bg-muted") works better than editing the code by hand, because the agent sees the correction in context for the rest of the session.

The page that uses the button stays a server component and just imports it:

tsx
import { CopyButton } from "@/components/ui/copy-button";

export default function Page() {
  return (
    <main className="flex items-center gap-3 p-8">
      <code className="rounded-md bg-muted px-2 py-1">npm i motion</code>
      <CopyButton value="npm i motion" />
    </main>
  );
}

Can I check the diff for design drift automatically?

Reading every diff by eye works until you're tired. This script runs the same check on whatever the agent changed: it flags added lines with hex colors or raw palette classes like bg-gray-100, and any line added to package.json. Edit the palette list to match what your tokens replace.

bash
#!/usr/bin/env bash
# Run after an agent edit, before you accept it.
status=0
if git diff --unified=0 HEAD -- 'src/**/*.tsx' 'src/**/*.css' | grep -E '^\+[^+]' \
  | grep -nE '#[0-9a-fA-F]{3,8}\b|(bg|text|border|ring|from|to)-(gray|slate|zinc|neutral|stone|blue|indigo|purple)-[0-9]{2,3}'; then
  echo "design drift: raw colors in the lines above"
  status=1
fi
if git diff --unified=0 HEAD -- package.json | grep -E '^\+\s+"'; then
  echo "new dependency: ask before keeping it"
  status=1
fi
[ "$status" -eq 0 ] && echo "no drift found"
exit "$status"

I tested it in a scratch repo. A change that only used token classes printed no drift found. A change that looked like typical agent output, with bg-gray-100, a hex text color and a new lucide-react dependency, printed:

2:+  return <button className="bg-gray-100 text-[#3b82f6]">Copy</button>;
design drift: raw colors in the lines above
+    "react": "19.1.0",
+    "lucide-react": "^1.60.0"
new dependency: ask before keeping it

The react line shows up because adding a dependency also changed the comma on the line before it. One catch: git diff HEAD doesn't see brand-new files the agent created. Run git add -N <file> on them first (it marks them as intended to add without staging content), and they show up in the check. Exiting with 1 means you can also run it as a pre-commit hook.

Verify with tsc and next build

The last step is the one people skip. A component can look right in next dev and still fail a production build: a missing "use client", a strict TypeScript error, or a prerender error from something that reads window. So the spec ends with the two commands I run after every agent change:

bash
npx tsc --noEmit
npx next build

Both passed on the test project with the rule, the spec, the component and the page above. I run them myself rather than trusting the agent's summary, because it will sometimes report that a build passed when it ran a different command or none at all.

What are the best practices for Cursor and UI work?

None of this is specific to one component. Once the rule exists, every UI request goes through it. The parts that pay off most:

  1. A scoped .mdc rule with token class names, file boundaries and the dependency rule.
  2. Real source plus a spec with a "Done when" list, instead of "add a nice X".
  3. A diff you read, plus the drift check, before you accept it.
  4. tsc and next build after every change.

qodesign OTP verify component, six digit boxes with a soft focus glow

It's also why every component on my components page ships with a PROMPT.md next to the source: the agent gets the file and the instructions together and has less to guess. If you want to try the workflow on real code without paying, the free pieces come with their full source.

I make animated React components at qodesign, each shipped with the source and a prompt file for Cursor, v0 or Lovable.

FAQ

How do I stop Cursor from breaking or overwriting older features?

Give it boundaries it can see: a rule that names the files it may touch, a spec for the current change, and a request for a diff before it applies anything. Commit before each agent run so you can compare and roll back, and reject changes outside the named files.

Why does Cursor keep changing my styles?

The agent writes the version of a component it has seen most often unless your tokens and conventions are in its context. A scoped rule in .cursor/rules with your token class names and a "no hex colors, no new tokens" line removes most of the guessing.

Where do Cursor rules go?

Project rules go in .cursor/rules as .mdc files with description, globs and alwaysApply in the frontmatter. A plain .md file there is ignored; for plain Markdown instructions use AGENTS.md in the project instead.

Do I still need to check the build if Cursor says it passed?

Yes. Run npx tsc --noEmit and npx next build yourself after every agent change. The agent's summary can describe a command it didn't run, and some errors, like a missing "use client", only show up in a production build.