UI.MD

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

In Progress

API Migration

Move endpoints to v2 schema

Planned

Push Notifications

Set up alerts for critical events

Done

Guidelines:

  • Animate only transform and opacity. They're GPU-composited, so they never force layout. The lone exception is height for disclosures (Collapsible, Card animateHeight): it's overflow-clipped and collapses instantly under reduced motion.
  • Never animate from scale(0), minimum scale(0.95). Elements should arrive from just off-stage, not materialize from nothing.
  • ease-out for entering, ease-in-out for on-screen movement.
  • Cap all animations at 300ms. Anything longer feels like the interface is showing off.
  • Honor prefers-reduced-motion as 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 own motion-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

gap-1
4px
gap-2
8px
p-3
12px
gap-4
16px
p-5
20px
h-8
32px

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:

  • 4px base 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-1 to gap-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-3 ends at 24px, so the text starts at pl-8 (32px). Padding tuned by eye is how one component ends up with three different gaps.
  • gap-4 or gap-5 between 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’s scale-95 and scale-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

Header + content
CardHeader pairs with CardContent.
Each region owns its own inset.
Rows

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 cardHeaderClass composed with cn(), 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 1540px container. Mobile-first, with the md breakpoint (768px) as the primary shift.
  • Width tokens size overlays and content: max-w-sm (320px) for popovers and tooltips, through max-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 sheets rounded-xl; badges and pills rounded-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) to rounded-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-md to sit concentrically inside a rounded-lg popup with p-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

shadow-ring-md, no border

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-1010
z-2020
z-4040
z-5050

Guidelines:

  • 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 1px spread layer and ship no border. 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-xs for form controls, shadow-sm for subtly raised surfaces, shadow-ring-md for cards, overlays, sheets, and drawers, shadow-ring-lg for modals. Plain shadow-md and shadow-lg are for surfaces that take no ring, like the inverted Tooltip, where a hairline would disappear against bg-foreground.
  • Stacking is four plain utilities, and every overlay shares the top one. z-10 elevates inside a component, z-20 a sticky region, z-40 the page header, and z-50 everything portaled to the body. There are no named z tokens: Tailwind v4 has no z-index theme namespace, so a class like z-modal is not a utility and silently resolves to z-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-50 the 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 arbitrary z-9999; start a new stacking context with isolation: 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 a z-50 written on the Popup is inert and the overlay drops to z-index: auto underneath 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 not static and read that element’s computed z-index.

Typography

Two families, one scale. Hierarchy comes from size, not weight.

Innovator Grotesk
SampleTokenSizeLine height
Caption texttext-xs13px1.5
Body texttext-sm14px1.5
Lead texttext-base16px1.5
Heading 4text-lg18px1.5
Heading 3text-xl20px1.375
Heading 2text-2xl24px1.375
Heading 1text-3xl28px1.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) to text-jumbo (45px). No arbitrary font sizes.
  • Body copy holds a 60 to 75 character measure with leading-reading; headings tighten to leading-tight or leading-snug.
  • Cap line length with max-w-measure (reading copy) and max-w-measure-heading (headings wrap short). Measured in ch, so the cap holds at every tier; go narrower, never wider. Note that ch is the zero glyph’s advance width, not an average character, so a ch value 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-measure always pairs with text-pretty, and max-w-measure-heading with text-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-nums for 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 a sm Button, a sm Select, and a sm Tabs 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-target overlay that grows the hit area to at least 44px under (pointer: coarse), without changing the visual size.
  • Mobile sizing: controls render text-base on mobile and md:text-sm on 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-accent ring-1; Switch uses focus-visible:ring-ring-focus ring-[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-allowed with reduced opacity. Input, Textarea, Select, and Checkbox use opacity-70; Switch uses opacity-50.
  • Labels: every control is tied to its label with htmlFor/id. Use sr-only labels 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: 150

Surfaces pair with a foreground

Aa

Page

Default page surface

--background
Aa

Card

Elevated panels

--card
Aa

Muted

Subdued backgrounds

--muted
Aa

Accent

Primary actions

--accent
Aa

Destructive

Danger actions

--destructive

Guidelines:

  • OKLCH color system: five primitive scales (neutral, accent, destructive, warning, success), each 11 steps (50 to 950) with perceptually uniform lightness.
  • Role, not hue. Tokens name a role (--accent, --destructive), never a color. Change --color-accent-h and every step of the scale retones together.
  • Semantic tokens resolve per mode: bg-card and text-foreground swap in :root and .dark. Surfaces flip; the accent stays constant at accent-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: *-100 in light, *-900 in 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 solid bg-accent fill; the destructive row already worked this way, on destructive-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-accent to bg-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: soft is the accent tint, a tinted sibling of the primary that still carries the brand, and subtle is 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 raw var() in component code. Tokens are defined once in globals.css, so consumers retheme by overriding :root alone.