Why UI prompts are different.
Prompting an agent to write a backend route is easy — the contract is in the function signature, the test pins the behavior, the diff is small. Prompting an agent to write a UI component is harder, because the contract is implicit and the failure modes are visual.
A vague backend prompt gives you a function that returns the wrong shape. A vague UI prompt gives you a working-looking button that fails on focus, ignores your tokens, can't be reached by keyboard, and looks slightly off-brand. None of it shows up in your test suite. All of it ships unless someone catches it in review.
The fix is not prompt engineering folklore. It's a short, repeatable structure with five required parts:
- Contract. Props and types. Treat as the function signature.
- Tokens. The allowed values for color, spacing, type, radius.
- States. Every state you expect the model to render.
- A11y. What "accessible" means in this codebase.
- Test. A line or two of how you'll verify.
The rest of this guide is each of those parts, with the words that actually work.
The component-contract pattern.
Give the model the type, not the wireframe. A TypeScript signature pins the contract more precisely than three paragraphs of description, in a quarter of the tokens.
// "Make a pricing card with a title, price, features, and a button. // It should look modern and have nice spacing."
// Implement <PricingCard /> in app/components/PricingCard.tsx export type PricingCardProps = { tier: 'free' | 'pro' | 'team'; price: { amount: number; currency: 'USD' | 'EUR'; interval: 'month' | 'year' }; features: ReadonlyArray<{ label: string; included: boolean }>; cta: { label: string; onClick: () => void; disabled?: boolean }; highlighted?: boolean; // renders the "most popular" treatment }; // Use existing tokens only (see tokens.css). Tailwind v4 classes allowed. // Use <Button /> from app/components/Button — do not create a new button.
Three things this prompt does that the bad one doesn't:
- Names the file, so the agent doesn't create a duplicate one folder over.
- Pins the props with a real type — the model can't invent a fourth tier or rename
cta.labeltobuttonText. - Reuses your existing primitives explicitly. Without that line, you get a parallel
Button.tsxnext door.
Design tokens first.
The fastest way to make AI-generated UI look like the rest of your app is to forbid raw values. Pass the tokens, name the file they live in, and tell the model the only allowed values are these.
# UI tokens — the only allowed values ## Color (Tailwind v4 vars in app/globals.css) bg: bg-surface, bg-surface-2, bg-elevated text: text-primary, text-secondary, text-muted accent: text-accent, bg-accent (hover: bg-accent-hover) danger: text-danger, bg-danger-subtle ## Spacing (4px grid) gap-1 (4) · gap-2 (8) · gap-3 (12) · gap-4 (16) · gap-6 (24) · gap-8 (32) p-2 · p-3 · p-4 · p-6 · p-8 (use these, not arbitrary px) ## Radius rounded-sm (4) · rounded (8) · rounded-lg (12) · rounded-full ## Type text-xs · text-sm · text-base · text-lg · text-xl · text-2xl font-normal · font-medium · font-semibold (no font-bold) ## Hard rules - No arbitrary values (no [13px], no [#aabbcc]). - No new colors. If you need one, ask, do not invent. - No inline styles. Tailwind classes only.
The model picks bg-blue-600 when your accent is oklch(0.55 0.16 250), which renders as a different blue. Multiply by 40 components and your app starts looking like two apps glued together. Token discipline is what stops that — not vibes, not review.
List the states, every time.
The single most common UI bug from AI is "looks great in the default state, breaks in the others." Spelling out the states is a 30-second exercise that returns a component you can ship.
| Always list | What to specify |
|---|---|
| Default | The thing you'd screenshot for marketing. |
| Loading | Skeleton, spinner, or "optimistic" — pick one and say which. |
| Empty | What renders when the array is [] — with copy. |
| Error | Message, retry affordance, where the error came from. |
| Hover / focus | Visible focus ring, hover treatment if any. |
| Disabled | Cursor, opacity, why it's disabled (tooltip?). |
| Long content | What happens with 5x the expected text or 50 rows. |
| Narrow viewport | One breakpoint behavior at minimum. |
Put this in the prompt:
# Required states
- default
- loading: full skeleton (no spinner)
- empty: friendly copy + "create your first" link
- error: inline message + retry button; do not toast
- focus: 2px focus ring on every interactive element
- disabled: opacity-60, cursor-not-allowed, no hover treatment
- long: wraps and truncates at 2 lines with title attribute
- <640px: stacks vertically, full-width CTAAccessibility, in one line.
Modern frontier models know WAI-ARIA Authoring Practices. They produce accessible components when you ask, and inaccessible components when you don't. Asking is one line.
# Accessibility (non-negotiable)
- Keyboard-operable: every action reachable and triggerable by tab/enter/space.
- Visible focus ring on every interactive element.
- Labels: every input has a programmatically-associated <label>.
- ARIA only when semantic HTML is insufficient; do not bolt ARIA onto a div.
- Color contrast: WCAG AA (4.5:1 body, 3:1 large text).
- axe-core must pass with zero violations in tests.Add an axe assertion to the test file so the model can verify itself before declaring done:
import { axe, toHaveNoViolations } from 'jest-axe'; import { render } from '@testing-library/react'; import { PricingCard } from './PricingCard'; expect.extend(toHaveNoViolations); test('PricingCard has no a11y violations', async () => { const { container } = render(<PricingCard tier="pro" {...fixture} />); expect(await axe(container)).toHaveNoViolations(); });
Generators vs IDE agents.
| Tool | Best for | Worst for |
|---|---|---|
| v0 (Vercel) | Greenfield Next.js + shadcn/ui prototypes. Quick visual iteration. | Shipping into an existing codebase with its own design system. |
| Lovable | Full-app generation for SaaS MVPs. Decent for non-developers. | Anything that needs to live alongside a hand-tuned existing app. |
| Magic Patterns | Component-level riffing with a strong visual editor. | Heavy logic / state machines. |
| Cursor / Claude Code / Codex | Components inside a real repo. Token-aware, primitive-reusing. | "Make it look pretty from scratch with no inputs" — you have to provide the brand. |
| Figma Make / Builder.io | When a designer hands you a Figma file and the round-trip matters. | Custom design systems that don't map cleanly to Figma's primitives. |
The result is your design system plus shadcn/ui plus a third style emerging from the seam between them. If the existing app has a design system, IDE agents are the right tool. Generators are for new surfaces with no inheritance to respect.
The same task, two prompts.
Same component, same model, same repo. The difference between a result you ship and a result you throw away is almost entirely upstream of the model.
Build a pricing card component. Make it look modern and clean. Should have three tiers and a CTA button. Use Tailwind.
The model invents a new visual language, a new button, a new spacing scale, and a new state model. You spend the next hour reconciling its taste with your design system — which is harder than writing the component from scratch.
Implement <PricingCard /> in app/components/PricingCard.tsx.
Contract:
type Tier = { name: string; priceMonthly: number; features: string[]; highlighted?: boolean };
props: { tiers: Tier[]; ctaLabel: string; onSelect: (tier: Tier) => void };
Constraints:
- Use tokens from app/styles/tokens.css. No arbitrary Tailwind values.
- Reuse <Button variant="primary" /> from app/components/Button.tsx. Do not create a new button.
- Match visual density of <SettingsCard /> in the same folder.
- States to handle: default, highlighted, loading (skeleton), empty tiers array.
- <640px: tiers stack vertically. >=640px: 3-column grid.
- Accessibility: each tier is a <button> or has role="button"; visible focus ring;
axe-core must report zero violations.
Verification:
- Add app/components/__tests__/PricingCard.test.tsx.
- Render with a 3-tier fixture; assert axe has zero violations; assert clicking
a tier calls onSelect with that tier object.
Out of scope: routing, analytics, animation.Notice what changed. File path. Type contract. Named primitives to reuse. A reference component for visual density. Enumerated states. A breakpoint. A specific accessibility check. A verification step. An out-of-scope list. None of these are clever. All of them are the work the model can't do for you — and once you've done them, the model is genuinely good at the rest.
Pitfalls.
Means nothing to a model and means something different to every reviewer. Replace with concrete tokens and a reference: "Match the visual density of SettingsCard.tsx; use the same heading scale; same border treatment." Specifics beat adjectives every time.
For anything beyond loading / error / data, hand the model the state machine yourself — a tiny XState chart, a discriminated union, or even just an enum. Otherwise you get five booleans (isLoading, isError, isEmpty, isStale, isRefetching) and a render function that can't possibly handle all 32 combinations correctly.
The model gets layout right and interaction wrong. Always pair the image with the states block. Vision is a useful input, not a complete one.
Pointing the model at one existing, well-built component ("match the prop API and structure of SettingsCard.tsx") is the cheapest quality boost you can buy. Two seconds in the prompt, dramatic improvement in the output.