Home Benchmarks Learn Tools News
Learn · Guides · Craft

Prompting for UI Code.

The patterns that turn AI agents from "produces a demo" into "produces a component you ship." Type-first specs, design-token discipline, and accessibility prompts that actually work — with a side-by-side of the same task prompted badly and prompted well.

SPONSOR

AppSignal — The component shipped. The error did too. AppSignal catches the runtime errors your test suite missed in AI-generated UI.

↗
On this page
  1. Why UI prompts are different
  2. The component-contract pattern
  3. Design tokens first
  4. List the states, every time
  5. Accessibility, in one line
  6. Generators vs IDE agents
  7. Example: bad prompt vs. shippable prompt
  8. Pitfalls
CH 01

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.

CH 02

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.

Bad: vague request
// "Make a pricing card with a title, price, features, and a button.
// It should look modern and have nice spacing."
Good: contract-first
// 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.label to buttonText.
  • Reuses your existing primitives explicitly. Without that line, you get a parallel Button.tsx next door.
CH 03

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.

Tokens block (paste into AGENTS.md)
# 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.
"It's just one shade off"

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.

CH 04

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
DefaultThe thing you'd screenshot for marketing.
LoadingSkeleton, spinner, or "optimistic" — pick one and say which.
EmptyWhat renders when the array is [] — with copy.
ErrorMessage, retry affordance, where the error came from.
Hover / focusVisible focus ring, hover treatment if any.
DisabledCursor, opacity, why it's disabled (tooltip?).
Long contentWhat happens with 5x the expected text or 50 rows.
Narrow viewportOne breakpoint behavior at minimum.

Put this in the prompt:

States block
# 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 CTA
CH 05

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

The one-line a11y constraint
# 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:

PricingCard.test.tsx
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();
});
CH 06

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.
"I'll just paste the v0 output into our app"

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.

EXAMPLE

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.

Prompt A — what most people write Throws away
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.

Prompt B — what ships Ships
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

Pitfalls.

"Make it look modern"

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.

Letting the model invent a new state machine

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.

Pasting a screenshot with no written spec

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.

No reference component

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.

What to read next.

  • Guide · 03 Cursor for Web Developers Where AGENTS.md, rules, and modes live — the home for your tokens block.
  • Guide · 06 AGENTS.md for Web Projects The persistent home for the tokens block and reference component map.
  • Guide · 09 Testing AI-Written Code The contracts the prompt promised must be the contracts the test pins.
Changelog
  • 2026-05-26Initial publish.
STATUS ● BUILDING THE FUTURE
MISSION LLM RESOURCES
VERSION BETA 3.0

BUILD WITH AI. SHIP WITH CONFIDENCE.

@WEBDEVELOPERHQ ↗
TERMS / PRIVACY
FRIENDS
Authentic Jobs
Authentic Jobs ↗
Web Reference
Web Reference ↗
Ready.dev
Ready.dev ↗
Design.dev
Design.dev ↗
© 2026 WEB DEVELOPER / ALL RIGHTS RESERVED