Single source of truth for every visual primitive in this app. Reuse what's here. Propose additions before building one-offs.
Colors
Ten semantic tokens, each defined for light and dark mode. Use the token names — never raw hex. Each swatch shows the value for the mode you're currently viewing; both hex codes are listed below the swatch and click-to-copy.
Surfaces
page
bg-page
surface
bg-surface
hairline
border-hairline
Text
ink-body
text-ink-body
ink-display
text-ink-display
ink-muted
text-ink-muted
Splash
accent
bg-accent / text-accent
accent-faded
bg-accent-faded
accent-display
bg-accent-display / text-accent-display
signal
bg-signal / text-signal
signal-faded
bg-signal-faded
signal-display
bg-signal-display / text-signal-display
danger
bg-danger / text-danger
danger-faded
bg-danger-faded
danger-display
bg-danger-display / text-danger-display
When to use
Always reach for a token first.
Use page / surface for layered backgrounds.
Use ink-display for headlines and emphasized content.
When not to use
Never use raw hex values (#1f2937) in components.
Don't introduce new color names ad-hoc — extend the system instead.
Two families. Headlines use Inter, body copy uses DM Sans. Both load from Google Fonts via <link> tags in the app's HTML <head>.
Display — Inter
The quick brown fox jumps over the lazy dog
Body — DM Sans
The quick brown fox jumps over the lazy dog. 0123456789. The five boxing wizards jump quickly.
When to use
Use font-display for headings only. Headings already inherit it via base styles.
Use font-sans for everything else — also already the default.
When not to use
Don't import additional font families — propose adding to the system instead.
Don't override font-family inline.
Sample code
// Set in design-system.css via @theme:
// --font-display: 'Stack Sans Text', ui-sans-serif, system-ui, sans-serif;
// --font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
<h1 className="font-display">Display headline</h1>
<p className="font-sans">Body paragraph.</p>
Shells
The outermost page frame. A shell is a full-bleed bg-page container with the main-navigation rail on the left and a content area on the right. The rail toggles between collapsed (w-14) and expanded (w-56) via a chevron button (state persisted to localStorage), and is hidden entirely below the lg breakpoint in favor of a fixed hamburger and slide-in drawer. Sub-navigation lives inside page headers as horizontal tabs (not in the shell).
When to use
Every authenticated screen.
Pair with a Page header at the top of the main content area.
When not to use
Marketing/landing pages — those use a dedicated marketing shell with a horizontal top nav.
Modals/sheets — they layer on top of the shell, not replace it.
Sample code
<div className="flex min-h-screen bg-page text-ink-body">
{/* Main navigation rail — see Main navigation section. Hidden below lg;
a hamburger fixed to the top-right opens the mobile drawer instead. */}
<MainNav />
<main className="min-w-0 flex-1 px-6 py-8 sm:px-10">
<div className="mx-auto max-w-4xl">
{/* Page header (optional with sub-nav tabs) → page content */}
</div>
</main>
</div>
Options & variations
Rail-only: just the main-nav rail and a content column. Default for app screens.
With sub-nav: page header inside main appends sub-navigation tabs — see Page headers "with tabs" variant. No second sidebar needed.
Content max-width: wrap children in mx-auto max-w-4xl so long-form content stays readable. The shell itself is full-bleed; the constraint lives on the inner container.
Mobile: hide the rail with hidden lg:flex and render a hamburger fixed to the top-right of the viewport (fixed right-3 top-3 z-30) that opens the same nav as a slide-in drawer overlay.
Main navigation
A vertical rail at the left edge of the shell. The rail has two fixed widths — collapsed (w-14, icons only) and expanded (w-56, icons plus labels) — and a chevron toggle switches between them. When expanded, the toggle sits in the brand row at the top; when collapsed, an expand chevron sits just above the user menu at the bottom of the rail. The choice is persisted to localStorage. When collapsed, hovering an item pops a tooltip-style label out to the right of the rail without resizing it. A user-account dropdown above the bottom edge holds a signed-in-as label, Profile / Settings, the theme toggle, and Sign out — and shows the avatar plus email when expanded, avatar only when collapsed. On screens narrower than lg, the rail is hidden in favor of a hamburger button fixed to the top-right corner that opens the same nav as a slide-in drawer.
Desktop — toggle between collapsed and expanded
When expanded, the collapse chevron sits next to the brand. State persists to localStorage.
Mobile — hamburger fixed to the top-right corner opens a drawer
Acme
Tap the menu to open the drawer
When to use
One per shell. Always present and sticky on desktop.
Top-level destinations only — secondary items belong in sub-nav tabs.
4–8 items max; beyond that, group with hairline separators.
When not to use
For deep, multi-level menus — use sub-nav tabs or a command palette.
Marketing/landing pages — those use a horizontal top nav.
Width: w-14 collapsed, w-56 expanded. Both are fixed; only the user-toggle changes between them.
Persistence: open/closed state is written to localStorage under main-nav-open (rename per app). Hydrate inside useEffect so SSR stays deterministic.
Toggle position: the collapse chevron lives in the brand row when the rail is expanded (next to the brand on the right). When the rail is collapsed there's no room for it there, so the expand chevron sits just above the user menu at the bottom of the rail.
Active item highlight (collapsed): each item is a 36px square pill (h-9 w-9) centered with mx-auto inside the 40px-wide nav column — keeps the bg-accent-faded highlight as a square rather than a full-width strip.
Floating labels (collapsed): each item is its own group/nav-item with an absolute left-full tooltip-style label. The label is pointer-events-none and only opacity-toggles, so the rail width is never disturbed.
Account menu: a Radix DropdownMenu with side="top" align="start" so it slides up from the bottom-left corner. Contents (top to bottom): signed-in-as label, Profile, Settings, separator, the <ThemeToggle block /> primitive, separator, Sign out. Trigger shows avatar + email when expanded, avatar only when collapsed.
Mobile: hide the rail with hidden lg:flex, render a hamburger fixed to right-3 top-3 with z-30 and bg-page so it stays readable while the page scrolls. The drawer is w-64 with its own brand row and close button at the top.
Sub navigation
Horizontal tab list for navigating within a single section of the app (settings sub-pages, project tabs, profile sections). The active tab's underline merges with the row's bottom hairline. An optional right-side slot accepts secondary links, filters, search fields, or small actions. Below the md breakpoint, the tab strip collapses into a full-width dropdown that surfaces the active tab — tap to switch sections without horizontal scrolling or wrapped rows.
Mobile (< md) — full-width dropdown
Desktop (md+) — tab strip
Desktop — with right-side content
When to use
When a page has 3+ sibling sub-views with stable structure.
Append to a Page header (see the "with tabs" variant).
Right-side slot is optional — leave empty when not needed.
When not to use
For 1–2 sub-pages — just link them inline in the page body.
For dynamic, list-of-things navigation — use a Listing.
For top-level app destinations — those belong in main navigation.
Mobile breakpoint: tabs collapse into a full-width <Select> below the md breakpoint. Show the dropdown with md:hidden and the tab strip with hidden md:flex. The dropdown's current value is the active tab so users can see and change sections without horizontal scrolling.
Active state: border-b-2 border-accent text-accent-display font-medium on the active tab; -mb-px overlaps the parent's hairline so the underlines merge into one line.
Right-side slot: any flex children — links, filters, search fields, small icon buttons. Use pb-2 to keep them visually centered against the tab text baseline. On mobile, stack right-side content above the dropdown trigger (use flex flex-col gap-2 on the wrapper) so the dropdown remains truly full-width.
Pair with Page header: drop the parent border-b off the page header, then place this nav beneath it. Result: two horizontal lines.
Rich dropdown variant: when tabs carry icons or status indicators that wouldn't survive a native <select>, swap the Select for a DropdownMenu with a full-width trigger and matching w-[var(--radix-dropdown-menu-trigger-width)] content width.
Page headers
The titled banner at the top of a page's main content area. Holds the page title, optional supporting copy, and a flexible right-side slot for actions. Optionally appends sub-navigation tabs below the title row to produce a two-line header.
Basic — with right-side actions
Projects
Everything your team is working on.
With search in the right slot
Members
Manage who has access to this workspace.
Single-item view — title with settings dropdown (the standard layout for viewing one item from a collection)
Onboarding redesign
With sub-navigation tabs (two horizontal lines)
Acme Inc.
Workspace settings and billing.
When to use
Every primary page inside the shell.
Right-side slot accepts buttons, icons, search inputs, links — use it for primary and secondary page actions.
Use the "with tabs" variant when the page has sub-views (settings, profile, project sections).
When not to use
Dialog/sheet contents — use DialogTitle instead.
Dense list views with tightly-packed filters — use a thinner toolbar pattern.
Right-side slot: a flex row that accepts any mix of buttons, icon buttons, links, badges, or search inputs. Place primary CTA at the far right; secondary actions to its left.
With breadcrumb: prepend a small breadcrumb row above the title.
With tabs: append the Sub navigation pattern below the title row's border-b — the tabs row gets its own border-b, producing two horizontal lines with the active tab's underline merging into the lower one.
Single-item view: when viewing one item from a collection, the right slot should hold our standard settings dropdown — see Dropdown menu. Use h-6 w-6 on the Ellipsis here (one size larger than in listing rows) to match the page header's heavier visual weight.
Body content
Wrap any long-form prose (articles, marketing copy, doc pages) in.body-content to get consistent vertical rhythm (space-y-6) and breathing room above sub-headings. We do not use Tailwind's typography plugin.
Section heading
Lead paragraph introducing the section. The body font and base type sizes come from the global stylesheet.
A second paragraph shows the consistent vertical rhythm given by .body-content > * + * with margin-top: 1.5rem.
Sub-section
Each h2, h3, or h4 inside.body-content gets pt-4 unless it's the first child.
Lists inherit base styles globally.
No extra Tailwind utility classes are needed.
And blockquotes look the same wherever they appear.
When to use
Articles, blog posts, documentation pages.
Anywhere you have a sequence of paragraphs and headings.
When not to use
UI chrome — buttons, forms, navs, cards.
Tightly-spaced layouts where you control gaps explicitly.
Sample code
<article className="body-content">
<h2>Section heading</h2>
<p>Lead paragraph introducing the section.</p>
<p>Continued prose with consistent vertical rhythm.</p>
<h3>Sub-section</h3>
<p>Each h2/h3/h4 inside .body-content gets pt-4 unless it's the first child.</p>
<ul>
<li>List items use the base styles defined globally.</li>
<li>No extra utility classes needed.</li>
</ul>
<blockquote>And blockquotes look the same wherever they appear.</blockquote>
</article>
Options & variations
.body-content applies vertical spacing only — no font or color overrides. Type comes from base styles.
Custom widths are fine: pair with max-w-prose or your own constraint.
Footers
The bottom edge of a shell. Quiet, low-contrast. Holds the copyright, legal links, and (sparingly) secondary navigation.
When to use
Public-facing or marketing pages.
Authenticated screens that have natural scroll endings.
When not to use
Dense app screens (dashboards, editors) — they don't need a footer.
asChild: render as a Slot — pass a single child element to inherit the styles.
Button dropdown
A split button — a primary action on the left and a chevron end-cap on the right that opens a dropdown of related secondary actions. The two halves render as one shape with a thin currentColor-tinted divider between them, so it works on any variant (and on custom backgrounds set via className). The menu uses the same dropdown primitive documented below.
When to use
One action is the obvious next step but a small set of related secondary actions belong in the same spot.
Page-header action boxes where the primary action lives next to "park / discard / reset"-style alternates.
When not to use
The actions don't share intent — use separate buttons (or a plain DropdownMenu if none of them is the obvious primary).
There's only the main action — use a plain Button.
The menu is a list of navigation destinations rather than secondary actions — use a plain DropdownMenu.
className / toggleClassName: extra classes for each half. Useful for custom-colored buttons (e.g. stage-color buttons in this app). toggleClassName defaults to mirror className.
menuAlign: start | center | end (default end) — forwarded to DropdownMenuContent.
toggleAriaLabel: aria-label for the chevron half. Default "More actions".
Children: pass DropdownMenuItem / DropdownMenuSeparator children — the standard dropdown-menu primitives.
Forms
Forms compose <Input>, <Select>,<Checkbox>, <Radio>, <RichTextField> (milkdown), native HTML labels, helper text, and <Button>. Vertical spacing between fields uses space-y-4; spacing inside a field uses space-y-2.
When to use
All data-entry surfaces.
Wrap fields with their own <label> for accessibility.
Use <fieldset> + <legend> around radio groups.
When not to use
Inline filters in toolbars — use compact controls instead.
Single-button calls to action — those don't need a form wrapper unless they POST.
Sample code
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Radio, RadioGroup } from "@/components/ui/radio";
import { Select } from "@/components/ui/select";
import { RichTextField } from "@/components/ui/rich-text-field";
<form className="space-y-4">
{/* Text input */}
<div className="space-y-2">
<label htmlFor="email">Email</label>
<Input id="email" type="email" placeholder="you@example.com" />
<p className="text-xs text-ink-muted">We'll only use this for account notifications.</p>
</div>
{/* Select */}
<div className="space-y-2">
<label htmlFor="country">Country</label>
<Select id="country" defaultValue="">
<option value="" disabled>Choose one…</option>
<option value="us">United States</option>
<option value="ca">Canada</option>
<option value="uk">United Kingdom</option>
</Select>
</div>
{/* Radio group */}
<fieldset className="space-y-2">
<legend>Plan</legend>
<RadioGroup>
<label className="flex items-center gap-2 text-sm font-normal text-ink-body">
<Radio name="plan" value="free" defaultChecked />
Free
</label>
<label className="flex items-center gap-2 text-sm font-normal text-ink-body">
<Radio name="plan" value="pro" />
Pro
</label>
<label className="flex items-center gap-2 text-sm font-normal text-ink-body">
<Radio name="plan" value="team" />
Team
</label>
</RadioGroup>
</fieldset>
{/* Checkbox */}
<label className="flex items-start gap-2 text-sm font-normal text-ink-body">
<Checkbox id="newsletter" defaultChecked className="mt-0.5" />
<span>
<span className="font-medium text-ink-display">Send me product updates</span>
<span className="block text-xs text-ink-muted">Roughly one email per month.</span>
</span>
</label>
{/* Rich text (milkdown) */}
<div className="space-y-2">
<label htmlFor="bio">Bio</label>
<RichTextField placeholder="Tell us about yourself…" />
</div>
<Button type="submit">Save</Button>
</form>
Options & variations
Use HTML <label htmlFor> with the field's id for accessibility.
Helper text uses text-xs text-ink-muted directly under the field.
Error states: render a text-xs text-danger-display message in the same slot as helper text and add aria-invalid to the field.
Radio groups: wrap in <fieldset> + <legend> and share a name across all <Radio> inputs.
Selects: use a disabled empty <option> as a placeholder when no default makes sense.
Pair with a small lucide icon as the first child for richer status badges.
Toggle buttons
Pill-shaped buttons with an on/off state. Use them as a small, inline filter or segmented control where one or more options can be active. Built as a base class so any <button> can adopt the look — no React primitive required.
When to use
Filter chips above a list ("All / Active / Archived").
Small segmented choices inside a toolbar.
Multi-select tag pickers where each pill toggles independently.
When not to use
For primary actions — use <Button>.
For yes/no settings — use a switch or checkbox.
For navigation — use anchors styled as tabs.
Sample code
// Class-based: pair .toggle-button with .toggle-button-on for the active state
<button type="button" className="toggle-button toggle-button-on">All</button>
<button type="button" className="toggle-button">Active</button>
<button type="button" className="toggle-button">Archived</button>
// Or drive the on state from aria-pressed (preferred for screen readers)
<button type="button" className="toggle-button" aria-pressed={true}>All</button>
<button type="button" className="toggle-button" aria-pressed={false}>Active</button>
<button type="button" className="toggle-button" aria-pressed={false}>Archived</button>
Options & variations
.toggle-button — base pill (off state). Renders on any <button>.
.toggle-button-onoraria-pressed="true" — active state. Both selectors are wired up; prefer aria-pressed for assistive tech.
Pair with a small lucide icon as the first child for icon+label pills.
Disabled state is handled automatically via disabled.
Listings
A vertical list of selectable rows — the workhorse of dashboards and resource indexes. Each row composes an icon, a title, supporting metadata, an optional Badge, a chevron affordance, and (for items in a collection) a standard settings dropdown.
Onboarding redesign
Updated 2h ago · Marie
Active
Q2 marketing rollout
Updated yesterday · Jordan
Active
API v2 migration
Updated 3d ago · Priya
Draft
Mobile launch checklist
Archived last week
Archived
When to use
Lists of resources (projects, files, members).
Search results, recent activity feeds.
When not to use
Dense, multi-column tabular data — use a table.
Inline option lists in dropdowns — use a menu primitive.
Wrap rows in <a> for navigation, or <button> for in-page selection.
Drop the chevron when the row is non-navigable.
Use truncate on title and subtitle to prevent overflow.
Settings dropdown by default. When listings render items in a collection (the typical case), include our standard settings dropdown at the right edge of each row — see Dropdown menu for the trigger pattern (bare Ellipsis, no border).
Data table
A vertical key/value table for displaying record details. Each row is a horizontal stripe with a title on the left and a value on the right. Rows stack on mobile; from md, the title takes 1 column and the value spans 3 (out of 4); from xl, the value spans 4 (out of 5) so wider screens give the value more room while the title stays the same size.
Status
Active
Owner
Marie Chen
Description
A longer value can wrap and flow across multiple lines without breaking the visual rhythm of the table. On wider screens the value takes more room so long copy doesn't have to wrap as aggressively.
Notes
Drop any node in here — text, a Badge, a list, a small grid of nested values.
Updated
May 16, 2026 at 1:27pm
When to use
Summarizing the fields of a single record (a video, a profile, a settings panel).
Read-only metadata views where each line is a labeled value.
Anywhere a long stack of <h3> + paragraph blocks is harder to scan than a labeled grid.
When not to use
Collections of similar items — use Listings instead.
Dense, multi-column tabular data — use a real <table>.
Editable forms — use the Forms primitives.
Sample code
<DataTable>
<DataRow title="Status">
<Badge tone="accent">Active</Badge>
</DataRow>
<DataRow title="Owner">Marie Chen</DataRow>
<DataRow title="Description">
A longer value can wrap and flow across multiple lines without breaking the
visual rhythm of the table.
</DataRow>
<DataRow title="Updated">May 16, 2026 at 1:27pm</DataRow>
</DataTable>
Options & variations
<DataRow title> accepts any node — usually a short label, but can include an icon or a small badge.
Values are text-ink-body by default. Drop in any node: text, a Badge, a list, a small grid of nested values.
Responsive grid: stacked (single column) under md — title sits above value, divider rhythm preserved. From md (768px), 4 columns with title md:col-span-1 and value md:col-span-3. From xl (1280px), 5 columns with value xl:col-span-4.
Wrap the table in a card (rounded-lg border border-hairline) if it needs to feel like its own panel.
Modal
Built on Radix Dialog. Three sizes (sm, md, lg). Always uses an overlay and a close button. Focus is trapped while open. If content is taller than the viewport, the modal caps its height and scrolls vertically so the top and bottom stay reachable.
When to use
Confirmation prompts (delete, discard, etc.).
Focused tasks that block the underlying view.
When not to use
Casual notifications — use a toast.
Long, scroll-heavy forms — use a dedicated page.
Stacked over another modal — flatten the flow instead.
Always include <DialogTitle> — required for screen readers.
Use <DialogDescription> for the supporting copy line.
Buttons live inside <DialogFooter>; right-aligned on desktop, stacked on mobile.
Content taller than the viewport scrolls vertically inside the modal automatically.
Dropdown menu
A floating menu attached to a trigger element. Trigger can be any button or icon. Items support leading icons; horizontal dividers (DropdownMenuSeparator) group related items. Built on Radix UI primitives — keyboard nav and focus management come for free.
Button trigger
Item settings dropdown
When to use
Account / user menus pinned to a header or rail.
Row-level "more actions" overflow menus on listings.
Filter / sort / view-mode pickers on dense screens.
When not to use
For form selection — use Select.
For navigation between top-level destinations — use main navigation.
For long, scrollable lists — use a Dialog or command palette.
Trigger: wrap any element with DropdownMenuTrigger asChild. Common triggers — a Button, an icon-only button, a label/badge.
Items with icons: pass a lucide icon as the first child of DropdownMenuItem. The primitive auto-sizes (h-4 w-4) and tones it to text-ink-muted.
Dividers: drop in <DropdownMenuSeparator /> between groups for a hairline horizontal rule.
Destructive items: pass destructive to DropdownMenuItem to tone it with the signal color.
Alignment: align="start" | "center" | "end" and side="top" | "right" | "bottom" | "left" on DropdownMenuContent.
Item settings dropdown — standard pattern. For row-level "edit / archive / delete" overflow menus on listings or single-item views, always use this exact trigger: Ellipsis icon, strokeWidth=1.5, no border or background, text-ink-muted with hover:text-ink-body. Use h-4 w-4 on listing rows; bump to h-6 w-6 when used in a page header for a single-item view.
Callout
A simple bordered container for emphasizing a chunk of content inside a longer page. Apply .callout to any element to get a hairline border, rounded corners, and comfortable padding.
Heads up — this is a callout. Wrap any content that needs a visual container (warning, tip, summary, side note) in .callout.
When to use
Tips, notes, warnings, or summary boxes inside long-form content.
Pull-out info that should feel separate from surrounding paragraphs.
Empty states or zero-data prompts in a panel.
When not to use
Modal or dialog content — use the Dialog primitive.
Items in a list — use the Listings pattern.
Form field grouping — use a fieldset, not a callout.
Sample code
<div className="callout">
<p>
Heads up — this is a callout. Wrap any content that needs a
visual container (warning, tip, summary, side note) in
<code>.callout</code>.
</p>
</div>
Options & variations
.callout is intentionally minimal: hairline border, rounded corners, padding. No background or color tone — pair with bg-surface or bg-accent-faded if you want emphasis.
Compose with text utilities (text-ink-display, text-ink-muted) and inline icons for richer variants (warning, info, success).
Heading scale
All six heading levels at their base sizes. Headings inherit the display font, semibold weight, and tightened line-height from base styles — no utility classes required.
For action triggers — use a Button (variant="link").
Inside dense UI nav lists — those have their own styles.
Sample code
<a href="/path">Read more</a>
Paragraph (p)
Body text element. Inherits color, font, and line-height from base styles.
A paragraph carries the bulk of textual content. Its color is ink-body, its line-height is 1.6, and it does not need any utility classes for its baseline appearance.
Sample code
<p>A paragraph of body text.</p>
Strong
Emphasized inline text. Bumps weight to 600 and color to ink-display.
Most of this sentence is body weight, but this part is emphasized.
Sample code
<p>This is <strong>important</strong>.</p>
Lists (ul / ol)
Lists are unstyled by default — no bullets, no numbers, no padding — so they're safe to use as semantic containers in nav, sidebars, and listings. To get default disc/decimal styling back, wrap the list (or its parent) in .body-content.
Individual list item. Unstyled by default; gets disc/decimal markers and indentation only when its parent list is inside .body-content.
A single list item inside .body-content.
Sample code
<div className="body-content">
<ul>
<li>A single list item.</li>
</ul>
</div>
Blockquote
Pull quote with an accent left border and italic, muted color. Use for attribution-style quotes inside body content.
"Make it work, make it right, make it fast — and only after that, make it pretty."
Sample code
<blockquote>"A pithy quote here."</blockquote>
Label / legend
Form-field labels (<label>) and <legend> use the design system's standard label style by default — small text, medium weight, ink-display color. No utility classes needed. The base rule applies to all <label> elements; for the special case of a label used as a wrapper around a checkbox or radio, add font-normal text-ink-body to override.