Design skill
The design skill is a rules file for your coding agent. Tokens decide the values; the skill decides how they go together: type sizes, spacing, corners, motion. Your agent reads it before it writes a screen.
1. What's in it
Each rule has one thing to write and one to avoid. Pick a rule to read it in full.
Type
- 1. Content text is 14px
- 2. Headings use sentence case
- 3. Never change letter spacing
- 4. No bold
- 5. Inline code is 0.9em
Spacing and alignment
- 6. Related things sit closer than unrelated things
- 7. Vertical padding around text is one step smaller
- 8. Icons line up with the first line of text
Surfaces
- 9. A layer with a shadow gets a ring, not a border
- 10. Nested corners are concentric
- 11. Don't put a Card inside a Card
- 12. Sticky bars are separated by a border
Motion and state
- 13. Hover changes color over 160ms
- 14. Every state change a component makes is visible in motion
- 15. Collapsing content keeps its size
- 16. Dialogs stay mounted and use `open`
Size
Loading
Opacity
Glass
2. Use it today
Copy skills/werewolf-design/SKILL.md into the folder your agent reads rules from.
3. Coming
npx werewolf-ui init will install it for you.
The rules
Type
1. Content text is 14px
Body copy, input values, table cells and md (default) and lg controls use 14px: text-body for paragraphs, text-md inside controls. 14px is the size the whole scale is tuned around, and mixing 14 and 15 or 16 in the same block looks like a mistake, not a hierarchy.
- 13px (
text-sm) is for secondary text next to a primary line: descriptions, helper and error text,smcontrols. - 12px (
text-xs) is for badges, tokens, captions and timestamps. - 16px and up (
text-lg,text-heading-*) is for headings andxlcontrols only.
// Do
<p className="text-body text-text-primary">Your trial ends in 12 days.</p>
// Avoid: body copy at 16px to "make it readable"
<p className="text-lg text-text-primary">Your trial ends in 12 days.</p>2. Headings use sentence case
Capitalize only the first word and proper nouns. Product and feature names keep the casing their owner uses. Title Case and uppercase make headings slower to read and louder than the content under them.
// Do
<h2 className="text-heading-3">Billing history</h2>
// Avoid
<h2 className="text-heading-3">Billing History</h2>
<h2 className="text-heading-3 uppercase">Billing history</h2>3. Never change letter spacing
No tracking-* classes and no letter-spacing in CSS. Geist and JetBrains Mono are already spaced for the sizes in the scale; tightening or loosening them is how generated UI starts to look generated.
// Do
<span className="text-xs font-medium text-text-secondary">Last 30 days</span>
// Avoid
<span className="text-xs font-medium tracking-wide uppercase text-text-secondary">Last 30 days</span>4. No bold
The scale has regular (400), medium (500) and semibold (600). There is no 700, and font-bold does nothing in a Werewolf project.
- Headings: the weight comes with the role (
text-heading-1to-3set it). Don't add a weight class on top. - Emphasis inside text, labels, button text:
font-medium. font-semiboldonly when medium can't separate a label from medium text right next to it.
// Do
<p className="text-body">Payment failed. <span className="font-medium">Update your card</span> to keep access.</p>
// Avoid
<p className="text-body">Payment failed. <b>Update your card</b> to keep access.</p>5. Inline code is 0.9em
Mono text sitting inside a sans sentence looks bigger than the words around it at the same size. Use code-inline: it sets the mono family at 0.9em, so it follows whatever size the sentence is.
// Do
<p className="text-body">Run <code className="code-inline">npm run build</code> again.</p>
// Avoid
<p className="text-body">Run <code className="font-mono">npm run build</code> again.</p>Spacing and alignment
6. Related things sit closer than unrelated things
A title and its description are one unit, so the gap between them is smaller than the gap to the next unit. If every gap is the same, the reader can't tell what belongs to what.
- Title to description, label to field, label to helper text:
gap-xsorgap-sm. - Field to field, header to body inside a card or dialog:
gap-lg. - Group to group:
gap-xl. Section to section:gap-2xlor more.
The inner gap must always be at least one step smaller than the gap outside it.
// Do
<div className="flex flex-col gap-lg">
<div className="flex flex-col gap-xs">
<h3 className="text-heading-3">Webhooks</h3>
<p className="text-sm text-text-secondary">Send events to your server.</p>
</div>
<Button>Add endpoint</Button>
</div>
// Avoid: one gap for everything
<div className="flex flex-col gap-md">
<h3 className="text-heading-3">Webhooks</h3>
<p className="text-sm text-text-secondary">Send events to your server.</p>
<Button>Add endpoint</Button>
</div>7. Vertical padding around text is one step smaller
Line height already adds space above and below the letters, so equal padding on all sides looks taller than it is wide. Around text, use one spacing step less on top and bottom than on the sides. Fixed-height controls (h-element-*) already handle this; the rule is for blocks you pad yourself: banners, callouts, list rows, table cells.
// Do
<div className="rounded-element bg-background-sunken px-lg py-md text-body">Changes are saved automatically.</div>
// Avoid
<div className="rounded-element bg-background-sunken p-lg text-body">Changes are saved automatically.</div>8. Icons line up with the first line of text
An icon next to text that can wrap goes in a box exactly one line tall, so it centers on the first line and stays there when the text wraps. Centering it on the whole block makes it drift to the middle of a paragraph. Use the icon size that matches the text (12px with text-xs, 16px with text-sm and text-md, 20px with text-lg and up) and color-icon-* for its color.
// Do
<div className="flex gap-sm text-body">
<span className="flex h-[1lh] shrink-0 items-center">
<IconInfoCircle size={16} className="text-icon-secondary" aria-hidden />
</span>
<p>Keys can't be viewed again after you close this dialog.</p>
</div>
// Avoid
<div className="flex items-center gap-sm text-body">
<InfoIcon size={16} weight="regular" aria-hidden />
<p>Keys can't be viewed again after you close this dialog.</p>
</div>Surfaces
9. A layer with a shadow gets a ring, not a border
Floating layers (menus, popovers, dialogs, toasts) use ring-1 ring-border for their edge. A ring is drawn with the shadow, outside the layout, so the edge stays sharp and the layer's size doesn't change. Cards that sit on the page have no shadow and keep border border-border.
// Do
<div className="rounded-container bg-background-raised ring-1 ring-border shadow-med">...</div>
// Avoid
<div className="rounded-container bg-background-raised border border-border shadow-med">...</div>Glass layers follow the same rule: material-thick with ring-1 ring-border and a shadow-* (rule 21).
In dark mode the shadow-* tokens already carry the layer's edge (pending review): a darker shadow plus a 1px light line inside the top edge. Put the layer on bg-background-raised and use the shadow class; don't add a highlight of your own (no light border-t, no inset-shadow-*, no extra ring), or the edge shows twice. A layer that draws its own edge highlight sets --werewolf-shadow-edge: 0 0 #0000 to drop the built-in one; the material-* classes already do.
The glossy Button variants (glossy, destructive-glossy) are the one control with a shadow, and they draw their own edges (pending review). Use the variant; don't build a raised control out of shadow-* classes and gradients.
10. Nested corners are concentric
When one rounded shape sits inside another and the gap between their edges is 8px or less, the outer radius equals the inner radius plus the gap. Otherwise the corners pinch at the inside and look wobbly.
Werewolf's radius steps are 4px apart (radius-inner, radius-element, radius-container, radius-page), so the steps are concentric exactly when the gap is 4px (p-xs):
- radius-inner inside radius-element with a 4px gap (a token in an input, a segment in a segmented control).
- radius-element inside radius-container with a 4px gap (a menu item in a menu).
A menu, select or combobox list has p-xs plus a 1px border, so its rows sit 5px in from the outer edge. Use rounded-item (radius-item, the container radius minus 5px) on those rows, not rounded-inner.
For any other gap up to 8px, compute the inner radius from the outer one. For gaps over 8px the corners are far enough apart that you just use the next step down. If the outer shape is rounded-full, the inner one is rounded-full too.
// Do: 4px gap, steps line up
<div className="rounded-container p-xs ring-1 ring-border shadow-med bg-background-raised">
<button className="rounded-element px-md h-element-sm w-full text-left">Rename</button>
</div>
// Do: 8px gap, compute it
<div className="rounded-container p-sm">
<div className="rounded-[max(calc(var(--radius-container)-var(--spacing-sm)),0px)]">...</div>
</div>
// Avoid: 8px gap with the 4px-gap step
<div className="rounded-container p-sm">
<div className="rounded-element">...</div>
</div>11. Don't put a Card inside a Card
Two borders and two paddings stacked inside each other waste space and blur what is grouped with what. Inside a Card, separate parts with spacing (rule 6), a divider (border-t border-border), or a sunken block (bg-background-sunken rounded-element). If the inner part really is its own object, the outer one probably shouldn't be a Card.
// Do
<Card>
<CardHeader>...</CardHeader>
<CardContent className="flex flex-col gap-lg">
<div className="rounded-element bg-background-sunken px-lg py-md">...</div>
</CardContent>
</Card>
// Avoid
<Card>
<CardContent>
<Card>...</Card>
</CardContent>
</Card>12. Sticky bars are separated by a border
A sticky header, toolbar or footer gets border-b border-border (or border-t for a footer) against the content scrolling under it. Shadows are reserved for floating layers (rule 9), and a sticky bar is not floating. If content should show through, use material-thin, which draws its own edge highlight and goes solid where blur is missing (rule 21).
// Do
<header className="sticky top-0 bg-background-page border-b border-border">...</header>
// Avoid
<header className="sticky top-0 bg-background-page shadow-low">...</header>Motion and state
13. Hover changes color over 160ms
Hover, press and focus color changes use duration-fast (160ms) with ease-standard. List the properties instead of using transition-colors or transition-all: those include outline-color, so the focus ring would fade in instead of appearing at once. The ring itself is never animated.
// Do
<a className="transition-[color,background-color,border-color] duration-fast ease-standard hover:overlay-hover focus-visible:focus-ring">...</a>
// Avoid
<a className="transition-colors duration-300 hover:overlay-hover">...</a>
<a className="transition-all hover:overlay-hover">...</a>Respect prefers-reduced-motion: color can still change, movement (press scale, slides) goes behind motion-safe:.
14. Every state change a component makes is visible in motion
A component is not done until its visible state changes animate. Interactive elements (buttons, toggles, checkboxes, inputs, menu items) transition their color, background and border properties with duration-fast ease-standard, and pressable controls add motion-safe:active:scale-pressed. Things that arrive or leave (dialogs, sheets, popovers, menus, tooltips, expanding rows) animate in and out: duration-medium (duration-slow for dialogs and sheets), ease-enter on arrival, ease-exit on exit, on the Sheet pattern:
<div className="transition-[translate,opacity] duration-slow ease-enter starting:opacity-0 motion-safe:starting:-translate-y-full">Static containers (Card, Separator, Badge, Kbd, EmptyState, Metadata, Field) need no motion of their own, but any interactive child they render must carry it. A component whose interactive parts are other components (Pagination uses Button, ToggleGroup uses Toggle, TableSelection uses Checkbox) inherits their motion.
15. Collapsing content keeps its size
When a section opens or closes, animate the height of a wrapper that clips (overflow-hidden), and let the content inside keep its full width and height the whole time. Don't animate the content's padding, font size or scale, and don't let it reflow while the wrapper shrinks; text that rewraps mid-animation looks broken. Use duration-medium, ease-enter to open and ease-exit to close.
// Do: the wrapper animates, the content stays the same size
<div className="grid transition-[grid-template-rows] duration-medium ease-enter data-[state=closed]:grid-rows-[0fr] data-[state=open]:grid-rows-[1fr]">
<div className="overflow-hidden"><div className="pt-md">...</div></div>
</div>
// Avoid: padding and content shrink together
<div className="transition-all data-[state=closed]:h-0 data-[state=closed]:py-0">...</div>16. Dialogs stay mounted and use `open`
Render the Dialog all the time and show or hide it with open and onOpenChange. Mounting it only when it should show skips the exit animation and can lose track of where focus should go back when it closes.
// Do
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>...</DialogContent>
</Dialog>
// Avoid
{open && (
<Dialog open onOpenChange={setOpen}>
<DialogContent>...</DialogContent>
</Dialog>
)}Size
17. Element sizes come from twelve classes
Control heights, widths and square slots such as icon buttons come from exactly these 12 classes, so they line up with Button and Input of the same size. Components take the same steps as their size prop: sm, md (the default), lg, xl. Touch (pointer: coarse) moves md, lg and xl up the ladder; sm keeps its look.
| Step | Height | Width | Width and height | Mouse | Touch (`pointer: coarse`) |
|---|---|---|---|---|---|
| sm | h-element-sm | w-element-sm | size-element-sm | 28px | 28px, hit area via size-target-min |
| md | h-element-md | w-element-md | size-element-md | 32px | 36px |
| lg | h-element-lg | w-element-lg | size-element-lg | 36px | 44px |
| xl | h-element-xl | w-element-xl | size-element-xl | 44px | 48px |
Not valid: no -xs element size (sm is the smallest control), no h-sm, w-sm or h-md shorthand. Use these classes.
Each step brings its own text and icon size: sm 13px text (text-sm) with 16px icons, md and lg 14px (text-md) with 16px icons, xl 16px (text-lg) with 20px icons. Use sm for toolbars, tables and dense menus, md everywhere else by default (props panels included: sm keeps its small look on a phone), lg for roomy forms and settings, xl for the primary action and touch-first screens. Dense or roomy UI is not a global setting: pick the step per region, and keep one step per region. Two neighbouring steps in one row (an md Input next to an lg Button) differ by only 4px with a mouse and look like a mistake.
Controls shorter than 44px on touch (sm, md, checkboxes, icon buttons) keep their look and extend their hit area to size-target-min. Inputs and textareas can't: on touch, Input sm is 28px and md is 36px. On touch-first screens use Input lg or xl, or wrap the field in a padded <label>.
// Do: one step for the whole row
<div className="flex gap-sm">
<Input size="lg" aria-label="Email" />
<Button size="lg">Subscribe</Button>
</div>
// Avoid: neighbouring steps side by side
<div className="flex gap-sm">
<Input aria-label="Email" />
<Button size="lg">Subscribe</Button>
</div>Loading
18. Never blank content while the next content loads
When the user changes page, tab, filter or page of a table, keep what is on screen until the next content is ready. Make the change in a transition (useTransition) and mark the region pending: pending on TabsContent and Table dims it to opacity-pending and sets aria-busy. Show a Skeleton only when there is nothing to keep (the first load), only after about 150ms, and for at least 300ms once shown (useSkeletonDelay), so it never flashes. Shape the skeleton like the content: text lines at the same font size (each line is h-lh tall), controls at the same size (shape="control"), the same corners. Wrap it in SkeletonGroup, which sets aria-busy and says "Loading" in a polite status. The pulse runs on duration-loop and stops under reduced motion.
// Do
const [isPending, startTransition] = useTransition()
<Tabs value={tab} onValueChange={(next) => startTransition(() => setTab(next))}>
<TabsContent value={tab} pending={isPending}>...</TabsContent>
</Tabs>
// Avoid
{isLoading ? <Spinner /> : <TabsContent value={tab}>...</TabsContent>}19. Images keep their space and fade in
Reserve an image's space before it loads (ratio, an aspect class or a height), show a small blurred version of it at once, and fade the full image in over it when it has loaded. Use Image with placeholderSrc: a data URL of a few hundred bytes made when the image is added, so it needs no request. In a Next.js app, next/image with placeholder="blur" does the same for static images. No fade under reduced motion.
// Do
<Image src={post.cover} placeholderSrc={post.coverBlur} alt={post.coverAlt} ratio="16 / 9" />
// Avoid
<img src={post.cover} alt={post.coverAlt} />Opacity
20. Opacity comes from three role tokens
There is no opacity scale. Use one of three tokens, by what the dim is for: opacity-pending on content kept on screen while the next content loads (pending on TabsContent and Table already applies it), opacity-disabled on non-text content in a disabled control whose color no token sets (a color swatch, an image, a photo avatar), opacity-subtle on decorative imagery that should recede. Never write another step (opacity-50, opacity-[.7]) or an alpha on a color class (text-text-primary/60, bg-accent/50). Fading something in or out goes between opacity-0 and opacity-100; that hides or shows it, it doesn't dim it.
Never use opacity to make text lighter. Secondary text uses text-text-secondary (hints and metadata text-text-tertiary), which are solved to keep their contrast on every background; text at an opacity takes whatever is behind it and has no checked contrast. Disabled labels and icons use text-text-disabled and text-icon-disabled, not opacity-disabled.
// Do
<p className="text-sm text-text-secondary">Updated 5 minutes ago</p>
<span className="size-4 rounded-inner opacity-disabled" style={{ background: value }} />
// Avoid
<p className="text-sm text-text-primary opacity-60">Updated 5 minutes ago</p>
<button disabled className="disabled:opacity-50">Save</button>Glass
21. Glass only on floating layers, two at most
Glass is one class per level: material-thin for bars (header, nav bar, tab bar), material-thick for sheets, popovers, dialogs and menus, material-refract for fixed-size elements only (tab bar, floating action button, nav bar). The class draws the fill, the blur, the edge and top highlights, and the solid fallbacks; never build glass from bg-*, backdrop-blur-* and an alpha color yourself. Its fill is just opaque enough for text-text-primary and text-text-secondary to keep 4.5:1 over anything behind it, so use only those two for text on glass.
- At most two glass layers visible on a screen at once. No glass on glass beyond that, so a menu over a glass sheet is the limit.
- Only floating and overlay layers. Never the page background, never a Card, never inside a long scrolling list or on list rows.
material-refractonly on an element whose size and corner radius never change. Anything that resizes, reflows with its content or animates its size usesmaterial-thick. Refraction draws only in Chromium-based browsers, afterenableMaterialRefraction()from thematerialregistry item marks the root; everywhere else it looks exactly likematerial-thick. Before a component uses it, check scrolling and animation by hand on an older iPhone and on a low-end Android phone.- Under reduced motion, no animated highlight: anything that moves a highlight goes behind
motion-safe:.
// Do
<SheetContent className="material-thick ring-1 ring-border shadow-high">...</SheetContent>
<header className="sticky top-0 material-thin">...</header>
// Avoid
<div className="bg-background-raised/60 backdrop-blur-lg">...</div>
<li className="material-thin">...</li>Before you hand UI back
- Every value is a token class; no hex, no px outside arbitrary values the rules above show.
- No
font-bold, notracking-*, no Title Case headings. - No
bordertogether with ashadow-*on the same element. - At most one glossy Button per view, and never next to a solid one:
glossyreplacessolid, it doesn't add a level. - Inner gaps are smaller than outer gaps.
- Nested radii follow rule 10.
- Every visible state change is in motion: color transitions, ease-enter arrivals, ease-exit departures, movement behind
motion-safe:(rule 14). - Element sizes come from the 12 classes and the
sizeprop, one step per region (rule 17). - Nothing on screen goes blank while the next content loads; skeletons match the layout they stand in for (rule 18).
- No opacity class but opacity-pending, opacity-disabled, opacity-subtle (and
opacity-0/opacity-100for a fade), no color alpha like/60, and no text made lighter with opacity (rule 20). - At most two glass layers on a screen, only on floating layers,
material-refractonly on fixed-size elements (rule 21).