Design principles
The rules behind every component. Constraints, not suggestions. Each one shown at work below.
Animation
Controlled animation feels responsive. Slow or mismatched easing feels broken.
Design Review
Weekly sync with the design team
API Migration
Move endpoints to v2 schema
Push Notifications
Set up alerts for critical events
Guidelines:
- Animate only
transformandopacity. They're GPU-composited, so they never force layout. The lone exception isheightfor disclosures (Collapsible, CardanimateHeight): it's overflow-clipped and collapses instantly under reduced motion. - Never animate from
scale(0), minimumscale(0.95). Elements should arrive from just off-stage, not materialize from nothing. ease-outfor entering,ease-in-outfor on-screen movement.- Cap all animations at
300ms. Anything longer feels like the interface is showing off. - Honor
prefers-reduced-motionas an allowlist, never a blanket duration kill. Vestibular motion (transform, scale, position) is dropped; color, opacity, and shadow keep animating, because they carry meaning and cause no discomfort. Every component ships its ownmotion-reduce:guard: the site's global fallback does not travel with an installed component, so a build gate fails on any unguarded movement.
Spacing
A consistent rhythm reduces cognitive load. The 4px base unit creates alignment you feel before you can name it.
Base unit and multipliers
Coupling: the gap signals the relationship
Tight (gap-1)
Title and description sit at 4px, so they read as one unit.
Loose (gap-4)
The same pair at 16px reads as two separate things.
Guidelines:
4pxbase unit via--spacing: 0.25rem. Tailwind multiplies:gap-2= 8px,p-4= 16px,h-8= 32px.- Icon-to-text scales with the control rather than sitting at one value:
gap-1togap-1.5(4 to 6px) inside a compact Button,gap-2(8px) on triggers and menu rows, where the row is taller and a tighter gap would let the glyph crowd the label.gap-1(4px) for title-to-description. - Clearance for an icon inside a control is derived, never eyeballed: it is where the adornment ends plus that control’s icon-to-text gap. An Input’s 12px icon at
left-3ends at 24px, so the text starts atpl-8(32px). Padding tuned by eye is how one component ends up with three different gaps. gap-4orgap-5between distinct groups: form fields, card sections, list items.- No arbitrary values is absolute, with two sanctioned exceptions. The Switch thumb travel (
translate-x-[calc(100%-2px)]) computes from the track’s own border, which no scale step can express. The press affordance (active:scale-[0.98], shared by Button, Checkbox, Radio, and Switch) sits between Tailwind’sscale-95andscale-100, and a 5% dip is too much travel for a control that only has to acknowledge the press.
Layout and composition
Children own their spacing; containers own structure. Composition over configuration.
One container, composed interior layouts
Notifications
Favorites
Compact
A single padded CardContent.
One backdrop: blur supplies depth
Reset notifications?
This clears your saved alert preferences.
Dialog, AlertDialog, Drawer, and Sheet share it. The blur de-emphasizes the page; the panel’s ring defines its edge.
Guidelines:
- Children own their spacing; containers own structure (border, radius, shadow, bg). A container never reaches in to pad its children.
- Cards have a small set of interior layouts (header plus content, body, showcase, compact), applied to the children via shared classes like
cardHeaderClasscomposed withcn(), not ad-hoc padding. - One backdrop for every overlay (Dialog, AlertDialog, Drawer, Sheet):
bg-black/10 backdrop-blur-md, carrying no shadow of its own. The blur supplies depth and de-emphasis; the panel keeps its tier shadow, and the ring folded into that shadow defines the edge. - Page content caps at a
1540pxcontainer. Mobile-first, with themdbreakpoint (768px) as the primary shift. - Width tokens size overlays and content:
max-w-sm(320px) for popovers and tooltips, throughmax-w-2xl(896px) for wide content, with dialogs, forms, and prose columns on the steps between.
Radius
One radius scale, assigned by tier. The corner tells you what kind of thing it is.
rounded-xs
Close buttons, micro
rounded-sm
Checkbox, small controls
rounded-md
Form controls, menu items
rounded-lg
Buttons, cards, modals
rounded-xl
Drawers, sheets
rounded-2xl
Unassigned
rounded-full
Badges, pills
Guidelines:
- Radius is tier-assigned, not chosen per element. Form controls and prose overlays (Popover, Tooltip, Toast) use
rounded-md; buttons, cards, Table, modals, and menu popups (Dropdown, Select, Command, Combobox)rounded-lg; drawers and sheetsrounded-xl; badges and pillsrounded-full. A Select is a form control at the trigger and a menu at the popup, so it takes both tiers, one on each part. - The scale runs
rounded-xs(4px) torounded-4xl(32px); the top three steps exist but carry no tier yet. No arbitrary radii; extend the scale instead. - Every step sits on the 4px spacing unit, half-steps only below 8px. The ladder is arithmetic, not a ratio of one base, so re-rounding the kit means moving the ladder in
@theme, never scaling a single knob. - Nested radii are concentric: outer radius = inner radius + gutter, where the gutter is the container’s padding. Size the container to its tier, then the inner element follows from it. Concentricity outranks the tier: menu items take
rounded-mdto sit concentrically inside arounded-lgpopup withp-1. That is also why menu popups round one rung above a plain Popover: a container holding interactive rows has to leave room for a row radius you would actually want, rather than squaring the rows down to fit the container. Once the gutter reaches the outer radius the corners stop interacting and the tier value stands.
Elevation and layering
Shadows say how high a surface lifts; four z-index rungs say what stacks above what, with every overlay sharing the top one.
shadow-xs
Form controls
shadow-sm
Subtle elevation
shadow-md
Plain drop shadow
shadow-lg
Heavy drop shadow
shadow-ring-md
Cards, overlays
shadow-ring-lg
Modals
Card surfaces
Elevated surfaces fold their hairline into the shadow stack instead of carrying a separate border, so the edge blends with the drop shadow rather than reading as a doubled line. Form controls are the other way round: a real border, plus shadow-xs.
Four rungs, and every overlay shares the top one
z-1010z-2020z-4040z-5050Guidelines:
- Multi-layer shadows for realistic depth. The plain
shadow-*values are OKLCH black at low opacity and hold in both modes unchanged; only the ring color flips, a faint dark hairline on light and a light top-lit one on dark, since a black ring would vanish against a dark surface. - An elevated edge is a ring, not a border. Cards, overlays, modals, sheets, and drawers fold a 1px hairline into the shadow stack as a
0 0 0 1pxspread layer and ship noborder. The ring follows the radius and blends with the drop shadow, so the edge reads refined instead of muddy. Real borders stay where the edge is structural: form controls, table rules, internal dividers. - Tier-assigned:
shadow-xsfor form controls,shadow-smfor subtly raised surfaces,shadow-ring-mdfor cards, overlays, sheets, and drawers,shadow-ring-lgfor modals. Plainshadow-mdandshadow-lgare for surfaces that take no ring, like the inverted Tooltip, where a hairline would disappear againstbg-foreground. - Stacking is four plain utilities, and every overlay shares the top one.
z-10elevates inside a component,z-20a sticky region,z-40the page header, andz-50everything portaled to the body. There are no named z tokens: Tailwind v4 has no z-index theme namespace, so a class likez-modalis not a utility and silently resolves toz-index: auto. - Never give an overlay its own z-index. Base UI portals Dialog, Sheet, Drawer, Popover, Dropdown, Select, and Tooltip to the body in mount order, so at a shared
z-50the last one opened wins. That single rule is what lets a Select inside a Dialog sit above it and a Dialog opened from a menu sit above the menu; a per-component ladder can only satisfy one of the two. Two overlays stacking wrong means a mount-order or close bug, not a missing tier. No arbitraryz-9999; start a new stacking context withisolation: isolate. - On a Base UI overlay the z-index belongs to the Positioner, not the Popup. Base UI positions the Positioner and leaves the Popup
position: static, where z-index is ignored, so az-50written on the Popup is inert and the overlay drops toz-index: autounderneath the header. It reviews clean because the class is right there in the string. Check it in the browser: walk up from the Popup to the first ancestor that is notstaticand read that element’s computed z-index.
Typography
Two families, one scale. Hierarchy comes from size, not weight.
| Sample | Token | Size | Line height |
|---|---|---|---|
| Caption text | text-xs | 13px | 1.5 |
| Body text | text-sm | 14px | 1.5 |
| Lead text | text-base | 16px | 1.5 |
| Heading 4 | text-lg | 18px | 1.5 |
| Heading 3 | text-xl | 20px | 1.375 |
| Heading 2 | text-2xl | 24px | 1.375 |
| Heading 1 | text-3xl | 28px | 1.375 |
Guidelines:
font-sans(Innovator Grotesk) for interface text,font-mono(Geist Mono) for code and technical values.- Hierarchy comes from size, not weight. Headings hold at
font-semibold; the kit ships no bold. - 12-step scale from
text-3xs(10px) totext-jumbo(45px). No arbitrary font sizes. - Body copy holds a 60 to 75 character measure with
leading-reading; headings tighten toleading-tightorleading-snug. - Cap line length with
max-w-measure(reading copy) andmax-w-measure-heading(headings wrap short). Measured inch, so the cap holds at every tier; go narrower, never wider. Note thatchis the zero glyph’s advance width, not an average character, so achvalue renders roughly 1.2x its own number in real characters: size the token by the count you measure, not by reading the number as characters. - A cap never travels alone.
max-w-measurealways pairs withtext-pretty, andmax-w-measure-headingwithtext-balance. A narrow column is exactly where a last-line orphan appears, so the cap that creates the risk carries its own fix. Both properties inherit, so setting them on a prose container covers every block inside it. - For long-form text, opt into
prose-flow: gaps scale with the type and a heading takes more space above than below, so it belongs to what follows. tracking-wide(0.08em) for uppercase labels;tabular-numsfor columns of figures. Standard text uses default tracking.
Form controls
Consistent heights and focus styles. Interact with the states below.
Guidelines:
- Height:
h-8(32px) is the default across Input, Select, Button, and the Combobox trigger. Everything that offers sizes rides one shared ladder,h-6/h-7/h-8/h-10(24 / 28 / 32 / 40px), which is why asmButton, asmSelect, and asmTabs or Nav strip all line up. Larger touch targets come from hit-area padding, not a taller control. - Touch targets: the visible control stays
h-8, but small or isolated controls add a.tap-targetoverlay that grows the hit area to at least 44px under(pointer: coarse), without changing the visual size. - Mobile sizing: controls render
text-baseon mobile andmd:text-smon desktop. The 16px mobile size stops iOS Safari from zooming on focus. - Focus: text controls (Input, Textarea, Select, Checkbox) use
focus-visible:border-accent+focus-visible:ring-ring-accentring-1; Switch usesfocus-visible:ring-ring-focusring-[3px]. - Invalid:
aria-invalid:border-destructive+aria-invalid:ring-ring-destructive. Always pair the ring with a visible error message beside the field, never color alone. - Validation: validate on blur or submit, not on every keystroke, and colocate the message directly beneath the field.
- Disabled:
cursor-not-allowedwith reduced opacity. Input, Textarea, Select, and Checkbox useopacity-70; Switch usesopacity-50. - Labels: every control is tied to its label with
htmlFor/id. Usesr-onlylabels only when a visible affordance already names the field.
Color
OKLCH for perceptual uniformity. Role-based tokens that retone from one variable and resolve per mode.
Primitive scales: 11 steps each
Neutral
Accent
Destructive
Warning
Success
Role, not hue: one variable retones the scale
--color-accent-h: 262.31--color-accent-h: 150Surfaces pair with a foreground
Page
Default page surface
--backgroundCard
Elevated panels
--cardMuted
Subdued backgrounds
--mutedAccent
Primary actions
--accentDestructive
Danger actions
--destructiveGuidelines:
- OKLCH color system: five primitive scales (neutral, accent, destructive, warning, success), each 11 steps (
50to950) with perceptually uniform lightness. - Role, not hue. Tokens name a role (
--accent,--destructive), never a color. Change--color-accent-hand every step of the scale retones together. - Semantic tokens resolve per mode:
bg-cardandtext-foregroundswap in:rootand.dark. Surfaces flip; the accent stays constant ataccent-500. - Intent colors (
--destructive,--warning,--success) only when the color carries information: a destructive action, a caution, a confirmation. - Alpha modifiers only on primitive scale tokens (
bg-neutral-500/10); semantic tokens are used at full value, never with alpha. - Selected and active tinted fills are solid scale steps, never alpha:
*-100in light,*-900in dark, paired with the role's text color (bg-accent-100 dark:bg-accent-900 text-accent). Tabs, the Nav pill, ghost hovers, badges, selected rows, and menu-row highlights (Dropdown, Select, Command, Combobox) all share this one recipe. A highlighted menu row is a selection like any other, so it gets the tint rather than a solidbg-accentfill; the destructive row already worked this way, ondestructive-100/900. - A state shifts a fill; it never swaps one variant for another. A filled control keeps its fill through hover and steps one stop deeper (
bg-accenttobg-accent-600); a transparent one gains the tint while its border and text hold steady. Hovering must never land on another variant’s rest state, or the control reads as having changed identity instead of reacting. - Two tonal fills, two roles, so neither has to do both jobs:
softis the accent tint, a tinted sibling of the primary that still carries the brand, andsubtleis the neutral tint, a quiet surface for toolbars. Each is the matching ghost hover made permanent. - Consume tokens through Tailwind utilities (
bg-card,text-foreground), never rawvar()in component code. Tokens are defined once inglobals.css, so consumers retheme by overriding:rootalone.