# Cursor Rules for a Design System: Add UI Without Drift

> 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.

- URL: https://qodesign.dev/blog/cursor-add-ui-component-without-breaking-design
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-19
- Updated: 2026-10-20
- Category: AI-built UI
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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.

## 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](https://cursor.com/docs/rules), 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:

```txt
---
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](https://tailwindcss.com/docs/theme).

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?

| File | When the agent reads it | What goes in it |
| --- | --- | --- |
| `.cursor/rules/*.mdc` | Always, by glob, or when the agent judges it relevant | Token class names, file boundaries, dependency rule |
| `AGENTS.md` | As plain project instructions, no frontmatter | Short, stable conventions for any agent, not only Cursor |
| `PROMPT.md` (your own spec) | Only when you attach it to a request | This 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:

```txt
# 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:

```txt
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](/previews/otp-verify.jpg)

It's also why every component on my [components page](/components) 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](/free) 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.
