frontend-design-saas

S-tier SaaS dashboard and product UI reference. Use this skill when building application shells, data tables, settings panels, billing pages, dashboards, auth…

npx skills add https://github.com/cloudflare/vibesdk --skill frontend-design-saas

SaaS Design System (Stripe / Linear / Vercel lineage)

AI-Optimized Design Reference for building S-tier SaaS dashboards, admin consoles, billing pages, settings UIs, data-dense application shells, and product surfaces.

Tonal anchors: stripe.com/dashboard, linear.app, vercel.com/dashboard, notion.so/teamspace, clerk.com, planetscale.com/console.


Quick Reference (TL;DR)

Brand Accent:    #5E6AD2 (indigo) - replace with product brand
Neutrals:        #FFFFFF → #F8FAFC → #F1F5F9 → #E2E8F0 → #94A3B8 → #475569 → #0F172A
Text:            #0F172A (primary) / #475569 (muted) / #94A3B8 (subtle)
Border:          #E2E8F0 (default) / #CBD5E1 (strong)
Font Sans:       "Inter", "Geist", system-ui (NOT Arial, NOT Roboto)
Font Mono:       "JetBrains Mono", "Geist Mono", ui-monospace
Base Spacing:    4px (scale: 4, 8, 12, 16, 20, 24, 32, 40, 48, 64)
Border Radius:   Buttons/inputs = 6-8px (NOT pill), Cards = 8-12px, Modals = 12-16px
Shadow:          Tight, low-spread, single-direction (NOT diffuse marketing shadows)
Density:         Compact-to-comfortable; row heights 36-44px, button heights 32-36px

The fastest way to get this wrong: use marketing-page aesthetics (huge type, generous whitespace, pill buttons, gradient hero shadows) in a product surface. SaaS UIs are dense, neutral, fast, and quiet. The brand color appears in <5% of the pixels and earns its weight by marking only what's interactive or active.


1. Brand Foundation

Design Philosophy

PrincipleDescription
Users FirstWorkflows, keyboard navigation, and information density beat decoration.
Quiet by DefaultNeutrals dominate; the brand color is reserved for primary action, active state, and focus.
Speed & DensitySnappy transitions (≤200ms), compact row heights, no entrance animations on every page load.
Opinionated DefaultsSingle canonical pattern per surface (one button hierarchy, one date picker, one empty state).
Meticulous Craft1px borders aligned to pixel grid, focus rings 2px offset 2px, motion uses ease-out not linear.
Accessible (WCAG AA+)4.5:1 text contrast, 3:1 UI element contrast, every interactive surface keyboard-reachable.
PredictableSame component looks/behaves identically on every page; no marketing-grade variants.

Visual Identity Rules

  • Do not use pure black on pure white. Use #0F172A on #FFFFFF (or #0B1220 on #0A0E1A in dark mode).
  • Brand color is the accent, never the dominant. If the brand color fills more than ~5% of the viewport in steady state, something is wrong.
  • Avoid pill buttons (border-radius: 9999px) for SaaS actions. Pills read as marketing/consumer; SaaS uses 6-8px radius for buttons and inputs.
  • No glassmorphism, no blur, no gradient borders. The aesthetic is precision, not richness.
  • Shadows are tight and downward. Not diffuse, not multi-direction. Reserved for floating layers (popovers, modals, dropdowns).
  • One font. Inter / Geist / IBM Plex Sans / native system stack. Avoid mixing display + body fonts; SaaS uses a single workhorse sans.
  • Mono for IDs, code, tokens, money. Tabular figures (font-variant-numeric: tabular-nums) for any column of numbers.

Reference apps to mentally calibrate against

Surface to buildLook at
Application shellLinear, Vercel, Stripe Dashboard
Data tableStripe /payments, Linear backlog, PlanetScale branches
Settings pageVercel project settings, Clerk dashboard, GitHub repo settings
Billing / invoiceStripe billing, Vercel usage
Empty stateLinear, Notion, Vercel deployments
Command paletteLinear ⌘K, Vercel ⌘K, Raycast
AuthClerk hosted pages, Stripe Connect onboarding
Onboarding checklistStripe Atlas, Vercel project import

2. Color System

2.1 Neutral Scale (the backbone)

SaaS UIs are 80–90% neutrals. The neutral scale carries the entire interface; brand is sprinkled on top. Use a slightly cool neutral (slate) by default — it reads more "product" than warm grays which feel marketing/lifestyle.

TokenHexUsage
--neutral-0#FFFFFFPage background (light mode), surface lifted onto canvas
--neutral-50#F8FAFCCanvas background, alternating row stripe
--neutral-100#F1F5F9Hover background, subtle fill, table header
--neutral-200#E2E8F0Default border, divider
--neutral-300#CBD5E1Strong border, input border on focus-within parent
--neutral-400#94A3B8Subtle text (placeholders, disabled, captions)
--neutral-500#64748BTertiary text, icon default
--neutral-600#475569Muted text (descriptions, secondary labels)
--neutral-700#334155Strong icon, hover text
--neutral-800#1E293BHeading in dense UIs, code text
--neutral-900#0F172APrimary text, primary icons
--neutral-950#020617Highest contrast (rare; only for critical labels)

2.2 Brand Accent (single color, used sparingly)

Pick one accent. Use a 6-step ramp so hover/active/focused/disabled all derive from the same hue. Defaults below use indigo (#5E6AD2, Linear's accent) — swap the hue for your brand but keep the structure.

TokenHexUsage
--brand-50#EEF0FBSelected row background, badge background, focus ring background
--brand-100#DDE0F7Hover on brand surfaces
--brand-200#B9C0EFDisabled brand button, decorative
--brand-500#7B85DCBrand hover surface stroke
--brand-600#5E6AD2Primary brand — default button bg, link text, focus ring, active nav item
--brand-700#4A56C0Hover state for primary button
--brand-800#3A45A8Active/pressed state
--brand-900#2A3284High-contrast brand text on light background

2.3 Semantic / Status Colors

Used for state — never for decoration. Each has a text, bg, border triplet so badges and inline messages stay legible.

Statetextbg (filled)bg-subtle (badge)border
Success#15803D#16A34A#DCFCE7#86EFAC
Warning#A16207#CA8A04#FEF9C3#FDE68A
Error / Destructive#B91C1C#DC2626#FEE2E2#FCA5A5
Info#1D4ED8#2563EB#DBEAFE#93C5FD
Neutral (default badge)#334155#64748B#F1F5F9#CBD5E1

Rules:

  • Inline form errors: text color on bg-subtle background, border for the input.
  • Toasts: bg-subtle panel, text heading, --neutral-700 body.
  • Status badges: text on bg-subtle + 1px border. Never use solid bg for badges (too loud).

2.4 Data Visualization Palette

When you need >1 color for charts. Categorical — visually distinct, similar luminance. Avoid the brand color in this palette so brand reads as "interactive" elsewhere.

--chart-1: #2563EB  (blue)
--chart-2: #16A34A  (green)
--chart-3: #CA8A04  (amber)
--chart-4: #DC2626  (red)
--chart-5: #9333EA  (violet)
--chart-6: #0891B2  (cyan)
--chart-7: #DB2777  (pink)
--chart-8: #65A30D  (lime)

2.5 Dark Mode Mapping

SaaS dark modes are not just inverted. They are slightly desaturated, slightly lifted, with carefully tuned elevation. Use #0A0E1A (or #0B0F1C) as the canvas — NEVER #000000. Pure black creates harsh contrast and exposes any banding in shadows.

TokenLightDark
--bg-canvas#F8FAFC#0A0E1A
--bg-surface#FFFFFF#111827
--bg-elevated#FFFFFF#1F2937
--bg-overlay (popover/modal)#FFFFFF#1F2937
--text-primary#0F172A#F1F5F9
--text-muted#475569#94A3B8
--text-subtle#94A3B8#64748B
--border-default#E2E8F0#1F2937
--border-strong#CBD5E1#374151
--brand-bg-subtle#EEF0FBrgba(94, 106, 210, 0.16)
--brand-fg#5E6AD2#A5B4FC

In dark mode the brand color shifts lighter (toward --brand-500/400 instead of --brand-600/700) to maintain contrast against the dark canvas.


3. Typography

3.1 Font Stack

--font-sans: "Inter", "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI",
             "SF Pro Text", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Geist Mono", "SF Mono", "Menlo", "Consolas",
             ui-monospace, monospace;

Hard rules:

  • Never use Arial, Helvetica, Roboto, or Times in a SaaS UI. These read as default/unstyled. If you can't load a webfont, fall through to system-ui instead.
  • Never mix two display fonts. One sans + one mono is the entire stack.
  • font-feature-settings: "cv11", "ss01", "ss03" for Inter (or whatever stylistic sets your font has) — small caps and alternate forms make tables look intentional.
  • font-variant-numeric: tabular-nums on EVERY column that contains numbers (money, dates, IDs, counts). Non-tabular figures make rows shimmer.

3.2 Type Scale (SaaS-tuned — denser than marketing)

TokenSizeLine HeightWeightLetter SpacingUse
text-2xs11px / 0.6875rem1.36 (15px)5000.04emMicrolabels (uppercase metadata, table column tags)
text-xs12px / 0.75rem1.33 (16px)400/5000Captions, badge text, table secondary
text-sm13px / 0.8125rem1.38 (18px)400/5000Default UI text: table rows, buttons, form labels
text-base14px / 0.875rem1.43 (20px)400/5000Body text, paragraph copy in panels
text-md15px / 0.9375rem1.47 (22px)400/5000Comfortable reading (descriptions, modals)
text-lg16px / 1rem1.5 (24px)500/600-0.005emCard headings, sidebar section labels
text-xl18px / 1.125rem1.44 (26px)600-0.01emSubsection headings
text-2xl20px / 1.25rem1.4 (28px)600-0.015emPage subheadings
text-3xl24px / 1.5rem1.33 (32px)600-0.02emPage title (h1 in dashboards)
text-4xl30px / 1.875rem1.2 (36px)600/700-0.025emMarketing-adjacent hero in settings

Critical: the default UI size in SaaS is 13px or 14px, NOT 16px. Stripe is 14px. Linear is 13px. Vercel is 14px. 16px feels too big and creates pages that scroll forever.

3.3 Weights

WeightValueUse
Regular400Body, table cells, descriptions
Medium500Default for buttons, labels, table headers, navigation, badge text
Semibold600Headings, emphasized stats, active nav
Bold700Rare — only marketing-adjacent surfaces or KPI numbers

Never use weight 800 or 900 in a SaaS UI.

3.4 Letter Spacing

ContextValue
Body / table0
Headings ≥18px-0.01em to -0.025em (tighter as size grows)
Microlabels / uppercase0.04em to 0.06em
Mono0

3.5 Text Rendering

html {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  font-feature-settings: "kern" 1, "liga" 1, "calt" 1;
}

.numeric, table, [data-numeric] {
  font-variant-numeric: tabular-nums;
}

4. Spacing System

4.1 Base Unit

--space-unit: 4px;

4.2 Scale

TokenpxremCommon Use
space-000Reset
space-px1Hairlines, dividers
space-0.520.125Tight icon-to-text gaps
space-140.25Inline tight
space-1.560.375
space-280.5Default tight gap, icon-text, badge padding
space-2.5100.625Input vertical padding (compact)
space-3120.75Input vertical padding (default), small card
space-4161Default card/panel padding, form field gaps
space-5201.25Section subheading gap
space-6241.5Card padding (comfortable), section internal
space-8322Page section gap, large card padding
space-10402.5Major section gap
space-12483Page-level section gap
space-16644Hero, max page-section

Density convention (which scale value to default to):

  • Buttons: 8px 12px (sm), 8px 14px (md), 10px 16px (lg) — vertical padding stays tight
  • Inputs: 8px 12px (sm), 9px 12px (md), 10px 14px (lg)
  • Cards: 16px (sm), 20-24px (md), 32px (lg / settings panel)
  • Table cells: 10px 16px (compact), 12px 16px (default), 16px 20px (comfortable)
  • Page padding: 24px (mobile) → 32px (desktop) → 48px (wide settings)

5. Border Radius

TokenValueUse
--radius-none0Table cells, full-bleed sections
--radius-sm4pxBadges, tags, small chips
--radius-md6pxDefault: buttons, inputs, dropdowns, menu items
--radius-lg8pxCards, panels, popovers
--radius-xl12pxModals, large surfaces, sheets
--radius-2xl16pxMarketing-adjacent (pricing card, onboarding)
--radius-full9999pxAvatars, status dots, NOT buttons

Rules:

  • Buttons in SaaS UIs use 6px, not pill (9999px). Pills are for marketing/consumer apps. The exception is a "tag" or "chip" inside a table cell.
  • Match input and button radius. If button = 6px, input = 6px. Visual consistency in form rows.
  • Cards never share radius with modals. Cards = 8px, modals = 12px+. This is how the modal reads as a different elevation layer.

6. Shadow System (Elevation)

SaaS shadows are tight, downward, single-direction. They mark elevation; they do not decorate. No glows, no colored shadows, no inset highlights, no large spread radii.

6.1 Elevation Tokens

/* Light mode */
--shadow-xs:  0 1px 2px 0 rgba(15, 23, 42, 0.04);
--shadow-sm:  0 1px 2px 0 rgba(15, 23, 42, 0.05),
              0 1px 3px 0 rgba(15, 23, 42, 0.06);
--shadow-md:  0 2px 4px -1px rgba(15, 23, 42, 0.06),
              0 4px 6px -2px rgba(15, 23, 42, 0.04);
--shadow-lg:  0 4px 6px -2px rgba(15, 23, 42, 0.05),
              0 10px 15px -3px rgba(15, 23, 42, 0.08);
--shadow-xl:  0 8px 10px -4px rgba(15, 23, 42, 0.06),
              0 20px 25px -5px rgba(15, 23, 42, 0.10);
--shadow-2xl: 0 25px 50px -12px rgba(15, 23, 42, 0.18);

/* Focus ring (NOT a shadow, but the only "glow" allowed) */
--ring-focus: 0 0 0 3px rgba(94, 106, 210, 0.20);
--ring-error: 0 0 0 3px rgba(220, 38, 38, 0.16);

/* Dark mode: shadows almost invisible; elevation is conveyed via background lightening */
--shadow-xs-dark:  0 1px 2px 0 rgba(0, 0, 0, 0.30);
--shadow-sm-dark:  0 1px 3px 0 rgba(0, 0, 0, 0.40);
--shadow-md-dark:  0 4px 8px -2px rgba(0, 0, 0, 0.50);
--shadow-lg-dark:  0 12px 20px -6px rgba(0, 0, 0, 0.55);

6.2 Elevation → Component Mapping

LayerShadowExamples
0 (flat on canvas)noneSidebar, page body, inline rows
1 (resting card)--shadow-xs or none + 1px borderDefault card on --bg-canvas
2 (hovered card)--shadow-smCard with :hover state
3 (dropdown, popover)--shadow-mdMenu, popover, select dropdown
4 (modal, dialog)--shadow-xlDialog, slide-over
5 (toast over modal)--shadow-2xlToast, command palette over modal backdrop

6.3 Border-vs-Shadow Decision

For most cards, prefer a 1px border over a shadow. Borders are crisper at any DPI and read more "product". Use shadow only when:

  • The element is floating over content (popover, modal, dropdown, toast)
  • A drag-and-drop ghost is rendered
  • A row is "lifted" to indicate selection while dragging

7. Animation System

7.1 Principle

SaaS motion is fast, ease-out, and only on user-initiated events. No entrance animations on page mount, no decorative parallax, no stagger on every grid. Motion's job is to confirm an action happened, not to entertain.

7.2 Easing

/* Default — used for most state transitions */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);   /* expo-out, snappy */

/* For things that recoil (modal entrance, popover) */
--ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);

/* Press / active (deceleration into rest) */
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);

/* Linear — for indeterminate progress / spinners only */
--ease-linear: linear;

Do NOT use ease, ease-in, or unspecified defaults. They feel sluggish in SaaS contexts.

7.3 Duration Scale

TokenmsUse
--duration-7575Color change, opacity flip (button press color)
--duration-100100Hover background fill
--duration-150150Default — most transitions
--duration-200200Dropdown/popover enter
--duration-250250Modal/dialog enter
--duration-300300Slide-over (drawer) enter
--duration-500500Skeleton shimmer cycle

Anything longer than 300ms in a product UI is wrong. Marketing pages can take more time; product UIs cannot.

7.4 Standard Transitions

/* Color/border (default for all interactive elements) */
button, a, input, select, textarea, [role="button"] {
  transition:
    background-color var(--duration-150) var(--ease-out),
    border-color var(--duration-150) var(--ease-out),
    color var(--duration-150) var(--ease-out),
    box-shadow var(--duration-150) var(--ease-out);
}

/* Modal/dialog */
.dialog-enter { animation: dialog-enter 250ms var(--ease-out-back); }
@keyframes dialog-enter {
  from { opacity: 0; transform: translateY(8px) scale(0.98); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}

/* Popover/dropdown */
.popover-enter { animation: popover-enter 150ms var(--ease-out); }
@keyframes popover-enter {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* Toast slide-in */
.toast-enter { animation: toast-enter 200ms var(--ease-out); }
@keyframes toast-enter {
  from { opacity: 0; transform: translateX(8px); }
  to   { opacity: 1; transform: translateX(0); }
}

/* Skeleton shimmer */
.skeleton {
  background: linear-gradient(90deg, var(--neutral-100) 25%, var(--neutral-200) 50%, var(--neutral-100) 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
  from { background-position: 200% 0; }
  to   { background-position: -200% 0; }
}

7.5 What NOT to Animate

  • Page mount. Render synchronously. The page is the priority, not the motion.
  • Sidebar nav items individually staggering in. Render the whole nav at once.
  • Tab content fade. Switching tabs is an instant context change; fade hides the change.
  • Table rows. A 1000-row table that fades in row-by-row is unusable.

7.6 What SHOULD Animate

  • Button hover (background-color 100ms)
  • Focus ring (box-shadow 150ms)
  • Modal/dialog enter and exit (250ms)
  • Popover/dropdown enter (150ms)
  • Toast slide-in (200ms)
  • Skeleton shimmer (loading states)
  • Accordion expand/collapse (height 200ms)
  • Optimistic UI confirmations (checkmark scale-in 150ms)

7.7 prefers-reduced-motion

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Always include. SaaS users may have vestibular sensitivity or use the OS-level reduce-motion setting.


8. Layout System

8.1 The Canonical SaaS Shell

┌─────────────────────────────────────────────────────────────────┐
│ [Topbar: workspace switcher | search ⌘K | user menu] 48px       │
├──────────────┬──────────────────────────────────────────────────┤
│              │                                                  │
│              │   Page header                                    │
│              │   ┌────────────────────────────────────────────┐ │
│              │   │ Title             [primary action] [⋯ menu]│ │
│   Sidebar    │   │ Description / breadcrumb                   │ │
│   (240-     │   └────────────────────────────────────────────┘ │
│    280px)    │                                                  │
│              │   Page body (cards, tables, settings)            │
│   Nav items  │                                                  │
│   Sections   │   ┌──────────────────────────────────────────┐   │
│   Footer     │   │  Card / Panel                            │   │
│              │   └──────────────────────────────────────────┘   │
│              │                                                  │
└──────────────┴──────────────────────────────────────────────────┘

This is the Stripe/Linear/Vercel/Notion layout. Three regions:

  1. Topbar (48–56px): workspace/team switcher on the left, global search (⌘K) center or right, user menu on far right. Stays fixed during scroll.
  2. Sidebar (240–280px): primary nav. Collapsible to a 56px icon rail on narrow screens. Stays fixed during scroll.
  3. Content (remaining width, max ~1200px centered for readability OR full-width for tables): page header → page body.

Variations:

  • Linear-style has no topbar — the sidebar takes the whole left edge, search lives inside the content header.
  • Notion-style has only a sidebar, page-level breadcrumbs at top of content.
  • Settings-style uses a secondary sidebar (settings nav) inside the content area, so two sidebars are visible.

8.2 Container Widths

TokenpxUse
--container-sm640Single-column forms, auth, dialogs
--container-md768Settings forms, account pages
--container-lg1024Dashboard pages, default content max
--container-xl1280Wide dashboards with sidebars-in-content
--container-2xl1536Data tables, monitoring views (full-bleed)

Tables get --container-2xl or full-bleed. Settings forms get --container-md (max ~720px reading column).

8.3 Breakpoints

Namemin-widthBehavior
sm640pxForm inputs reach full width on phones
md768pxTwo-column form layouts kick in
lg1024pxSidebar appears (below this, sidebar becomes a sheet/drawer)
xl1280pxSidebar stays expanded; data tables show all columns
2xl1536pxMulti-column dashboards

Mobile rule: the sidebar collapses to a hamburger that opens a full-height sheet. The topbar's search and user menu remain.

8.4 Grid Patterns

/* Page header — title left, actions right */
.page-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 24px;
}

/* Two-column form (label left, input right) — settings page idiom */
.form-row {
  display: grid;
  grid-template-columns: minmax(0, 240px) minmax(0, 1fr);
  gap: 24px;
  padding: 20px 0;
  border-bottom: 1px solid var(--border-default);
}
.form-row:last-child { border-bottom: none; }
.form-row__label { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.form-row__hint  { font-size: 12px; color: var(--text-muted); margin-top: 4px; }

/* Card grid (3-col on desktop, 2 on tablet, 1 on mobile) */
.card-grid {
  display: grid;
  gap: 16px;
  grid-template-columns: 1fr;
}
@media (min-width: 768px) { .card-grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .card-grid { grid-template-columns: repeat(3, 1fr); } }

/* KPI strip — auto-fit, never wider than 280, never narrower than 200 */
.kpi-strip {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 12px;
}

8.5 Sidebar Anatomy

[Workspace switcher]      32px row, hover bg, dropdown chevron
─────────────────────
SECTION LABEL             11px uppercase, --neutral-500, 8px y-padding
  [icon] Item 1           36px row, 13px text, 8px gap, hover bg-100
  [icon] Item 2           selected: bg-brand-50 + brand-700 text
  [icon] Item 3
─────────────────────
SECTION LABEL
  [icon] Item 4
─────────────────────
                          (stretches to fill)
[avatar] user@email       Bottom; opens user menu upward

Key spec:

  • Sidebar bg: --bg-canvas (slightly tinted from page surface) OR --bg-surface flat
  • Nav items: 36px tall, 8px horizontal padding, 6px radius, gap 8px between icon + text
  • Active item: bg: var(--brand-50), color: var(--brand-700), no border, no left-accent stripe (Linear-style minimal)
  • Section labels: text-2xs uppercase, --neutral-500, 8px y-padding
  • Width: 256px default, 56px collapsed (icons only)

9. Dark Mode

9.1 Detection

<script>
  (function() {
    var stored = localStorage.getItem('theme');
    var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    var dark = stored === 'dark' || (stored !== 'light' && prefersDark);
    document.documentElement.classList.toggle('dark', dark);
  })();
</script>

Inline in <head> BEFORE any stylesheets to prevent flash-of-light-theme on dark-preferred users.

9.2 CSS Token Mapping

:root {
  --bg-canvas: #F8FAFC;
  --bg-surface: #FFFFFF;
  --bg-elevated: #FFFFFF;
  --text-primary: #0F172A;
  --text-muted: #475569;
  --text-subtle: #94A3B8;
  --border-default: #E2E8F0;
  --border-strong: #CBD5E1;
  --brand-fg: #5E6AD2;
  --brand-bg-subtle: #EEF0FB;
}

.dark {
  --bg-canvas: #0A0E1A;
  --bg-surface: #111827;
  --bg-elevated: #1F2937;
  --text-primary: #F1F5F9;
  --text-muted: #94A3B8;
  --text-subtle: #64748B;
  --border-default: #1F2937;
  --border-strong: #374151;
  --brand-fg: #A5B4FC;
  --brand-bg-subtle: rgba(94, 106, 210, 0.16);
}

9.3 Three-Way Theme Toggle

Always offer system | light | dark. The user expects to match OS preference by default but be able to override.

function setTheme(mode /* 'system' | 'light' | 'dark' */) {
  if (mode === 'system') {
    localStorage.removeItem('theme');
    const dark = matchMedia('(prefers-color-scheme: dark)').matches;
    document.documentElement.classList.toggle('dark', dark);
  } else {
    localStorage.setItem('theme', mode);
    document.documentElement.classList.toggle('dark', mode === 'dark');
  }
}

matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
  if (!localStorage.getItem('theme')) {
    document.documentElement.classList.toggle('dark', e.matches);
  }
});

9.4 Dark-Mode-Specific Rules

  • Borders go invisible. In dark mode, a 1px border with rgba(255, 255, 255, 0.08) reads cleaner than the inverted neutral. Use a slightly translucent border for surfaces, solid --border-default for input fields.
  • No drop shadows. Or, dramatically reduce them. In dark mode, elevation is conveyed by lighter surface backgrounds, not by darker shadows underneath.
  • Brand color shifts lighter. --brand-fg becomes #A5B4FC (brand-300/400 range) so it has enough contrast against the dark canvas.
  • Image / chart luminance. If you render charts/screenshots inside a card, the card background should be --bg-elevated (lighter than canvas) so the artwork doesn't appear floating on void.

10. Accessibility

10.1 Color Contrast

Minimum ratios:

  • Body text on background: 4.5:1 (WCAG AA Normal Text)
  • Large text (≥18px or 14px bold) on background: 3:1
  • UI components (button border, input border, focus ring) against adjacent color: 3:1
  • Non-text indicators (icons that convey state): 3:1

Verify the included palette:

  • #0F172A on #FFFFFF = 19.3:1 ✓
  • #475569 (muted) on #FFFFFF = 7.5:1 ✓
  • #94A3B8 (subtle) on #FFFFFF = 3.6:1 ✓ (large text / decorative only)
  • #5E6AD2 (brand) on #FFFFFF = 5.7:1 ✓
  • #FFFFFF on #5E6AD2 (brand button) = 5.7:1 ✓

10.2 Focus States

/* Default focus ring for all interactive elements */
*:focus { outline: none; }
*:focus-visible {
  outline: 2px solid var(--brand-fg);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

/* For elements with their own visual treatment (buttons, inputs), use box-shadow ring */
button:focus-visible,
[role="button"]:focus-visible,
a:focus-visible {
  outline: none;
  box-shadow: var(--ring-focus);
}

input:focus-visible,
textarea:focus-visible,
select:focus-visible {
  outline: none;
  border-color: var(--brand-fg);
  box-shadow: var(--ring-focus);
}

Never remove the focus ring without replacing it. This is the single most common SaaS accessibility regression.

10.3 Keyboard Map (every SaaS app should implement)

KeyAction
⌘K / Ctrl+KOpen command palette
/Focus the page search input
g then dGo to dashboard (vim-style nav, optional but Linear/GitHub do it)
?Show keyboard shortcut cheat sheet
EscClose modal, dismiss popover, blur input
Tab / Shift+TabMove focus forward/back
EnterActivate focused button/link; submit form when input focused
SpaceToggle checkbox/switch; activate button
/ Navigate list items, table rows, menu items
j / kSame as ↓ / ↑ (vim-style; optional)

10.4 Semantic Patterns

  • Buttons use <button>, links use <a href>. Never use a <div onClick> for either.
  • Form fields have a visible <label> (or aria-label if visually replaced by a placeholder pattern).
  • Required fields: visible * marker AND aria-required="true".
  • Error messages: linked via aria-describedby, role alert for the first error.
  • Tables: <th scope="col"> headers, optional <caption> for context.
  • Modals: trap focus, restore focus to trigger on close, aria-modal="true", role="dialog", labeled by heading.
  • Toasts: role="status" for non-critical, role="alert" for errors.

10.5 Touch Targets

Minimum 44×44px (WCAG 2.5.5). On desktop SaaS, buttons can be 32-36px tall as long as the clickable hit area (via padding) reaches 44px.

.icon-button {
  position: relative;
  width: 32px;
  height: 32px;
}
.icon-button::before {
  content: '';
  position: absolute;
  inset: -6px;  /* extends hit area to 44x44 */
}

10.6 Screen Reader Utilities

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.sr-only-focusable:focus {
  position: static;
  width: auto;
  height: auto;
  clip: auto;
  white-space: normal;
}

Use .sr-only for "Skip to main content" links and icon-only button labels.


11. Decorative Elements (Used Sparingly)

SaaS UIs avoid heavy decoration. The few decorative idioms that DO appear:

11.1 Section Dividers

.divider {
  height: 1px;
  background: var(--border-default);
  margin: 24px 0;
}

/* Vertical divider (e.g., between toolbar groups) */
.divider-v {
  width: 1px;
  background: var(--border-default);
  align-self: stretch;
  margin: 0 8px;
}

11.2 Inline Pills / Status Dots

/* Status dot (used in tables: ● Active) */
.status-dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 9999px;
  vertical-align: middle;
  margin-right: 6px;
}
.status-dot--success { background: #16A34A; }
.status-dot--warning { background: #CA8A04; }
.status-dot--error   { background: #DC2626; }
.status-dot--neutral { background: #94A3B8; }

/* Pulsing dot for "live" / "syncing" */
.status-dot--pulse {
  position: relative;
}
.status-dot--pulse::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: inherit;
  animation: pulse-ring 1.6s ease-out infinite;
}
@keyframes pulse-ring {
  0%   { transform: scale(1);   opacity: 0.6; }
  100% { transform: scale(2.5); opacity: 0; }
}

11.3 Empty-State Iconography Placeholder

For empty states, draw a single 1.5px-stroke line icon at 48–64px size in --neutral-300. Do not use 3D illustrations or stock vectors — they break the "quiet product" register.

<div class="empty-state">
  <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor"
       stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
       style="color: var(--neutral-300);">
    <!-- single line-art icon (e.g., a Lucide icon) -->
  </svg>
  <h3>No customers yet</h3>
  <p>Customers appear here after their first successful payment.</p>
  <button>Create test customer</button>
</div>

11.4 Subtle Background Grid (for empty canvases)

For dashboard backgrounds, an OPTIONAL very subtle dot grid is acceptable:

.canvas-grid {
  background-color: var(--bg-canvas);
  background-image: radial-gradient(circle, var(--neutral-200) 1px, transparent 1px);
  background-size: 24px 24px;
  background-position: 0 0;
}

Opacity: never above 30%. Most SaaS apps omit this entirely.

11.5 Keyboard Shortcut Chips

.kbd {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  padding: 2px 6px;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.2;
  color: var(--text-muted);
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-bottom-width: 2px;  /* mechanical key look */
  border-radius: 4px;
  vertical-align: middle;
}

Usage: <span class="kbd">⌘</span><span class="kbd">K</span> inside tooltips, menu items, command palette.


12. Component Quick Reference

Buttons

VariantBackgroundTextBorderUse
primary--brand-600#FFFFFFnoneThe one primary action per surface
secondary--bg-surface--text-primary--border-default 1pxMost actions ("Cancel", "Save", "Edit")
ghosttransparent--text-primarynoneToolbar buttons, table row actions
destructive#DC2626#FFFFFFnoneDelete, revoke, irreversible
destructive-outline--bg-surface#B91C1C#FCA5A5Less aggressive destructive
linknone--brand-600none, underline on hoverInline navigation actions

Sizes: sm (28px tall, 12px text), md (32px tall, 13px text — default), lg (40px tall, 14px text).

Inputs

StateBorderBackgroundShadow
default--border-default--bg-surfacenone
hover--border-strong--bg-surfacenone
focus--brand-fg--bg-surface--ring-focus
error#DC2626--bg-surface--ring-error
disabled--border-default--neutral-50none, opacity: 0.6

Sizes match buttons: sm (28px), md (32px default), lg (40px).

Cards

VariantBackgroundBorderPaddingShadow
default--bg-surface1px --border-default20pxnone
interactive--bg-surface1px --border-default20pxhover: --shadow-sm
ghostnonenone0none — for grouping without enclosure
elevated--bg-elevatednone24px--shadow-md
outline-onlynone1px --border-default16pxnone — for placeholder/empty slots

Badges / Status Pills

VariantBackgroundTextBorder
neutral--neutral-100--neutral-700none
brand--brand-50--brand-700none
success#DCFCE7#15803Dnone
warning#FEF9C3#A16207none
error#FEE2E2#B91C1Cnone
info#DBEAFE#1D4ED8none

Sizing: padding 2px 8px, font 11–12px, weight 500, radius 4px (NOT pill). Optional leading status dot.


13. File Structure Recommendation

project/
├── src/
│   ├── styles/
│   │   ├── tokens.css           # All CSS custom properties (one file, single source of truth)
│   │   ├── base.css             # Reset, html/body, typography defaults, focus
│   │   └── globals.css          # @import the above + utility classes
│   ├── components/
│   │   ├── primitives/          # Lowest-level, design-token-aware
│   │   │   ├── Button.tsx
│   │   │   ├── Input.tsx
│   │   │   ├── Select.tsx
│   │   │   ├── Checkbox.tsx
│   │   │   ├── Switch.tsx
│   │   │   ├── Badge.tsx
│   │   │   ├── Card.tsx
│   │   │   ├── Avatar.tsx
│   │   │   ├── Tooltip.tsx
│   │   │   ├── Kbd.tsx
│   │   │   └── Spinner.tsx
│   │   ├── overlays/            # Floating layers
│   │   │   ├── Dialog.tsx
│   │   │   ├── Sheet.tsx
│   │   │   ├── Popover.tsx
│   │   │   ├── DropdownMenu.tsx
│   │   │   ├── Toast.tsx
│   │   │   └── CommandPalette.tsx
│   │   ├── data/                # Data display
│   │   │   ├── DataTable.tsx
│   │   │   ├── Pagination.tsx
│   │   │   ├── KpiCard.tsx
│   │   │   └── EmptyState.tsx
│   │   ├── forms/
│   │   │   ├── FormField.tsx
│   │   │   ├── FormRow.tsx
│   │   │   └── FormSection.tsx
│   │   └── shell/               # The app frame
│   │       ├── AppShell.tsx
│   │       ├── Sidebar.tsx
│   │       ├── Topbar.tsx
│   │       ├── PageHeader.tsx
│   │       └── Breadcrumb.tsx
│   └── lib/
│       ├── theme.ts             # Theme toggle (system/light/dark)
│       ├── shortcuts.ts         # Global keyboard handler
│       └── format.ts            # Money, date, relative time helpers
├── tailwind.config.ts           # Tokens mirrored from tokens.css
└── tsconfig.json

Rules:

  • tokens.css is the single source of truth. tailwind.config.ts reads from it (or duplicates its values, but only as a mirror).
  • Primitives have no business logic. They accept props, render styled DOM. State lives in feature components that wrap them.
  • No component crosses layer boundaries. A primitive can't import from shell/; shell/ can't import from data/.

14. Full CSS Custom Properties

Copy into styles/tokens.css:

:root {
  /* ── Brand ── */
  --brand-50:  #EEF0FB;
  --brand-100: #DDE0F7;
  --brand-200: #B9C0EF;
  --brand-500: #7B85DC;
  --brand-600: #5E6AD2;
  --brand-700: #4A56C0;
  --brand-800: #3A45A8;
  --brand-900: #2A3284;
  --brand-fg: var(--brand-600);
  --brand-bg-subtle: var(--brand-50);

  /* ── Neutrals (slate) ── */
  --neutral-0:   #FFFFFF;
  --neutral-50:  #F8FAFC;
  --neutral-100: #F1F5F9;
  --neutral-200: #E2E8F0;
  --neutral-300: #CBD5E1;
  --neutral-400: #94A3B8;
  --neutral-500: #64748B;
  --neutral-600: #475569;
  --neutral-700: #334155;
  --neutral-800: #1E293B;
  --neutral-900: #0F172A;
  --neutral-950: #020617;

  /* ── Semantic surfaces ── */
  --bg-canvas:   var(--neutral-50);
  --bg-surface:  var(--neutral-0);
  --bg-elevated: var(--neutral-0);
  --bg-hover:    var(--neutral-100);

  --text-primary: var(--neutral-900);
  --text-muted:   var(--neutral-600);
  --text-subtle:  var(--neutral-400);

  --border-default: var(--neutral-200);
  --border-strong:  var(--neutral-300);

  /* ── State ── */
  --success-fg:  #15803D;
  --success-bg:  #DCFCE7;
  --warning-fg:  #A16207;
  --warning-bg:  #FEF9C3;
  --error-fg:    #B91C1C;
  --error-bg:    #FEE2E2;
  --info-fg:     #1D4ED8;
  --info-bg:     #DBEAFE;

  /* ── Chart palette ── */
  --chart-1: #2563EB;
  --chart-2: #16A34A;
  --chart-3: #CA8A04;
  --chart-4: #DC2626;
  --chart-5: #9333EA;
  --chart-6: #0891B2;
  --chart-7: #DB2777;
  --chart-8: #65A30D;

  /* ── Typography ── */
  --font-sans: "Inter", "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI",
               "SF Pro Text", system-ui, sans-serif;
  --font-mono: "JetBrains Mono", "Geist Mono", "SF Mono", "Menlo", "Consolas",
               ui-monospace, monospace;

  /* ── Spacing (multiples of 4) ── */
  --space-unit: 4px;

  /* ── Radius ── */
  --radius-none: 0;
  --radius-sm:   4px;
  --radius-md:   6px;
  --radius-lg:   8px;
  --radius-xl:   12px;
  --radius-2xl:  16px;
  --radius-full: 9999px;

  /* ── Shadows ── */
  --shadow-xs:  0 1px 2px 0 rgba(15, 23, 42, 0.04);
  --shadow-sm:  0 1px 2px 0 rgba(15, 23, 42, 0.05), 0 1px 3px 0 rgba(15, 23, 42, 0.06);
  --shadow-md:  0 2px 4px -1px rgba(15, 23, 42, 0.06), 0 4px 6px -2px rgba(15, 23, 42, 0.04);
  --shadow-lg:  0 4px 6px -2px rgba(15, 23, 42, 0.05), 0 10px 15px -3px rgba(15, 23, 42, 0.08);
  --shadow-xl:  0 8px 10px -4px rgba(15, 23, 42, 0.06), 0 20px 25px -5px rgba(15, 23, 42, 0.10);
  --shadow-2xl: 0 25px 50px -12px rgba(15, 23, 42, 0.18);
  --ring-focus: 0 0 0 3px rgba(94, 106, 210, 0.20);
  --ring-error: 0 0 0 3px rgba(220, 38, 38, 0.16);

  /* ── Motion ── */
  --ease-out:        cubic-bezier(0.16, 1, 0.3, 1);
  --ease-out-back:   cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-in-out:     cubic-bezier(0.4, 0, 0.2, 1);
  --duration-75:  75ms;
  --duration-100: 100ms;
  --duration-150: 150ms;
  --duration-200: 200ms;
  --duration-250: 250ms;
  --duration-300: 300ms;

  /* ── Layout ── */
  --container-sm:  640px;
  --container-md:  768px;
  --container-lg: 1024px;
  --container-xl: 1280px;
  --container-2xl: 1536px;
  --sidebar-w:        256px;
  --sidebar-w-collapsed: 56px;
  --topbar-h:         48px;
}

.dark {
  --bg-canvas:   #0A0E1A;
  --bg-surface:  #111827;
  --bg-elevated: #1F2937;
  --bg-hover:    rgba(255, 255, 255, 0.04);

  --text-primary: #F1F5F9;
  --text-muted:   #94A3B8;
  --text-subtle:  #64748B;

  --border-default: #1F2937;
  --border-strong:  #374151;

  --brand-fg:         #A5B4FC;
  --brand-bg-subtle:  rgba(94, 106, 210, 0.16);

  --success-fg: #4ADE80;
  --success-bg: rgba(22, 163, 74, 0.16);
  --warning-fg: #FACC15;
  --warning-bg: rgba(202, 138, 4, 0.16);
  --error-fg:   #F87171;
  --error-bg:   rgba(220, 38, 38, 0.16);
  --info-fg:    #60A5FA;
  --info-bg:    rgba(37, 99, 235, 0.16);

  --shadow-xs:  0 1px 2px 0 rgba(0, 0, 0, 0.30);
  --shadow-sm:  0 1px 3px 0 rgba(0, 0, 0, 0.40);
  --shadow-md:  0 4px 8px -2px rgba(0, 0, 0, 0.50);
  --shadow-lg:  0 12px 20px -6px rgba(0, 0, 0, 0.55);
  --shadow-xl:  0 20px 32px -10px rgba(0, 0, 0, 0.60);
  --shadow-2xl: 0 30px 60px -15px rgba(0, 0, 0, 0.70);
}

/* ── Base ── */
html {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  font-feature-settings: "kern" 1, "liga" 1, "calt" 1, "cv11" 1, "ss01" 1;
}

body {
  margin: 0;
  background: var(--bg-canvas);
  color: var(--text-primary);
  font-family: var(--font-sans);
  font-size: 14px;
  line-height: 1.43;
}

.numeric, table, [data-numeric="true"] {
  font-variant-numeric: tabular-nums;
}

*:focus { outline: none; }
*:focus-visible {
  outline: 2px solid var(--brand-fg);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

15. Forensic Visual Analysis

Design Language DNA & Implementation Physics Reverse-engineered patterns from Stripe Dashboard, Linear, Vercel, Notion, Clerk, PlanetScale.

15.1 Visual Hierarchy & Spatial Logic

Density Tier

TierRow HeightButton HeightDefault TextWhen
Compact (Linear, dev tools)32px28px13pxPower-user surfaces, data-heavy tables
Default (Stripe, Vercel)40px32px14pxThe default SaaS product UI
Comfortable (Notion, Clerk)48px36px15pxContent-heavy, settings, billing
Spacious (Auth, onboarding)56px+40px+16pxSingle-purpose conversion pages

Pick one tier per product, not per page. Mixing density breaks the rhythm.

Layout Constants

Content width:
├── Tables / lists:       full bleed within --container-2xl (1536px)
├── Dashboards:           --container-xl (1280px) centered
├── Settings forms:       --container-md (768px) — readable column
└── Auth / onboarding:    --container-sm (640px) centered

Page padding (X):
├── Mobile:    16px (px-4)
├── Tablet:    24px (px-6)
└── Desktop:   32px (px-8)

Page padding (Y):
├── Page header → content:     16-24px
├── Section → section:         32-40px
└── Last section → page end:   64px (so footer doesn't crowd)

Grid gaps:
├── KPI cards:           12px
├── Card grids:          16px (sm) → 20px (md) → 24px (lg)
├── Form fields:         16px (tight) or 20px (default)
└── Settings rows:       0 — separated by 1px bottom border

The "Three Heights" Rule

In a SaaS UI, three vertical rhythms coexist:

  1. Page rhythm: 24px / 32px / 48px between page sections.
  2. Component rhythm: 12px / 16px / 20px inside cards and panels.
  3. Inline rhythm: 4px / 6px / 8px between adjacent elements (icon + label, badge + text).

Each rhythm is a multiple of 4 but does not reuse the same numbers from another rhythm. Inline gaps stay ≤8px, component gaps stay 12–20px, page gaps stay ≥24px. This is what makes a SaaS UI feel "organized" without conscious effort.

15.2 Color Science & Elevation

How elevation is conveyed (without heavy shadows)

LayerLight ModeDark Mode
Canvas#F8FAFC (1 step down from white)#0A0E1A
Surface (default card)#FFFFFF + 1px border#111827 + 1px rgba(255,255,255,0.04) border
Elevated (popover)#FFFFFF + --shadow-md#1F2937 + low shadow
Modal#FFFFFF + --shadow-xl + backdrop blur#1F2937 + --shadow-xl + backdrop blur

In light mode, elevation = subtle shadow + 1px border. In dark mode, elevation = LIGHTER background (the higher the layer, the lighter the bg) + nearly-invisible shadow.

Brand color is "interaction language"

  • Brand color = "this is interactive, this is selected, this is focused."
  • Anything else uses neutrals.
  • Status colors (success/error/etc.) are ONLY for status — never for buttons that don't represent that status.

A useful test: squint at the UI. You should see 90% gray-on-white, with brand pinpricks marking exactly the interactive surface you'd click.

Selected-state convention

StateConvention
HoverBackground shifts by 1 step (--bg-surface--bg-hover)
Active (pressed)Background shifts by 2 steps OR scale(0.98)
Selected (persistent)--brand-bg-subtle background + --brand-fg text. No border change.
Focused (keyboard)--ring-focus box-shadow ring. Independent of hover/selected.
Disabledopacity: 0.5 + cursor: not-allowed. Color unchanged.

15.3 Typography & Micro-Copy

The "13/14 default" rule

The default body/UI size is 13px or 14px. Headings step up modestly (no 48px headlines in a dashboard). The biggest text in a typical product page is the H1 at 24–30px.

ElementSizeWeight
H1 (page title)24px600
H2 (section)18px600
H3 (card heading)15-16px600
Body / table cell13-14px400
Label / button13-14px500
Microlabel / caption11-12px500 (uppercase optional)
Mono (id, code, money)12-13px400

Micro-copy patterns

Buttons — verb + object, never just verb:

  • ✅ "Create customer", "Save changes", "Delete invoice"
  • ❌ "Submit", "OK", "Click here"

Empty state headings — declarative, not interrogative:

  • ✅ "No active subscriptions"
  • ❌ "You don't have any subscriptions yet, would you like to create one?"

Inline help / hints — single sentence, no period (because it's not a sentence in form context):

  • ✅ "Used for receipts and customer emails"
  • ❌ "This email will be used for receipts and customer emails."

Destructive confirmation — type-to-confirm for irreversible:

  • "Type acme-corp to confirm deletion" — see GitHub's repo deletion modal.

Toast text — past tense, with optional action:

  • ✅ "Invoice sent. [Undo]"
  • ❌ "Sending invoice…" (use a loading state for in-flight)

15.4 Component Anatomy

Button (the most-touched primitive)

┌────────────────────────────────────┐
│  [icon] [label]            [⌘K]    │   ← gap 8px between icon/label, 12px to shortcut
└────────────────────────────────────┘
   ↑ padding 12-14px              ↑ padding 12-14px
   ─ height: 32px (default)
   ─ radius: 6px
   ─ font: 13px / 500

Variants:

  • primary — solid brand bg, white text. ONE per surface.
  • secondary — surface bg, neutral border, primary text. Most actions.
  • ghost — no bg, no border. Toolbar / row actions.
  • destructive — solid red bg. Reserved for irreversible.

States:

  • default → hover (bg shifts) → active (bg shifts more / scale 0.98) → focus (ring) → disabled (opacity 0.5)

Forbidden:

  • Pill radius (9999px) — that's marketing
  • Gradient background — that's marketing
  • Drop shadow — buttons are flat against the surface

Input

┌────────────────────────────────────┐
│  [icon] placeholder text      [×]  │   ← optional leading icon, trailing clear
└────────────────────────────────────┘
   ─ height: 32px (default)
   ─ radius: 6px
   ─ border: 1px --border-default
   ─ padding: 0 12px (or 0 36px when icon present)
   ─ font: 14px / 400

Focus state: border → --brand-fg, add --ring-focus box-shadow. Never use solid background change for focus — borders + ring are the convention.

Error state: border → #DC2626, ring → --ring-error. Show error message below input in #B91C1C, 12px, with aria-describedby linking.

Card

┌──────────────────────────────────┐
│  Heading            [actions]    │   ← header row, 16-20px from edges
│  Optional description            │
├──────────────────────────────────┤   ← 1px divider OR padding gap
│                                  │
│  Card body                       │
│                                  │
└──────────────────────────────────┘
   ─ radius: 8px
   ─ border: 1px --border-default
   ─ background: --bg-surface
   ─ padding: 20-24px
   ─ shadow: none (default)

The card has a 1px border by default. NO shadow. NO inner glow. The border is what carries the elevation — it implies a layer.

Interactive cards (e.g., clickable list items): hover lightens border to --border-strong AND adds --shadow-sm. Don't change the background.

15.5 Animation Physics

Speed map

Color/border transitions:     100-150ms
Hover state:                  100ms
Focus ring appearance:        150ms
Dropdown / popover enter:     150ms
Modal / dialog enter:         200-250ms
Slide-over (drawer) enter:    300ms
Toast slide-in:               200ms
Tab switch:                   instant (no animation)
Page mount:                   instant
Skeleton shimmer cycle:       1500ms
Spinner rotation:             750ms per turn

Easing map

Linear → only for indeterminate progress / spinners
ease-out (expo-out, --ease-out) → DEFAULT for everything else
ease-out-back → modal/popover entrance (subtle overshoot)
ease-in-out → reversible transitions (accordion height)
ease-in → almost never; exit animations only

The "no entrance animation" rule

Page mount animations are bad UX in SaaS:

  • The user already navigated; the destination is the priority.
  • An entrance animation delays perceived load time.
  • Staggered grids look "designed" on first view and tedious on the second.

The ONLY place entrance animation belongs in a product UI is overlay layers: modals, popovers, toasts. These are appearing on top of an already-visible page, so the motion is meaningful (where did this come from?).

15.6 Technical Deliverables

Tailwind Configuration

// tailwind.config.ts
import type { Config } from 'tailwindcss';

const config: Config = {
  darkMode: 'class',
  content: ['./src/**/*.{ts,tsx,js,jsx,html}'],
  theme: {
    extend: {
      colors: {
        brand: {
          50:  '#EEF0FB', 100: '#DDE0F7', 200: '#B9C0EF',
          500: '#7B85DC', 600: '#5E6AD2', 700: '#4A56C0',
          800: '#3A45A8', 900: '#2A3284',
        },
        neutral: {
          0:   '#FFFFFF', 50:  '#F8FAFC', 100: '#F1F5F9',
          200: '#E2E8F0', 300: '#CBD5E1', 400: '#94A3B8',
          500: '#64748B', 600: '#475569', 700: '#334155',
          800: '#1E293B', 900: '#0F172A', 950: '#020617',
        },
        canvas:   'var(--bg-canvas)',
        surface:  'var(--bg-surface)',
        elevated: 'var(--bg-elevated)',
      },
      fontFamily: {
        sans: ['Inter', 'Geist', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'system-ui', 'sans-serif'],
        mono: ['JetBrains Mono', 'Geist Mono', 'SF Mono', 'Menlo', 'Consolas', 'ui-monospace', 'monospace'],
      },
      fontSize: {
        '2xs':  ['11px', { lineHeight: '15px', letterSpacing: '0.04em' }],
        xs:     ['12px', { lineHeight: '16px' }],
        sm:     ['13px', { lineHeight: '18px' }],
        base:   ['14px', { lineHeight: '20px' }],
        md:     ['15px', { lineHeight: '22px' }],
        lg:     ['16px', { lineHeight: '24px', letterSpacing: '-0.005em' }],
        xl:     ['18px', { lineHeight: '26px', letterSpacing: '-0.01em' }],
        '2xl':  ['20px', { lineHeight: '28px', letterSpacing: '-0.015em' }],
        '3xl':  ['24px', { lineHeight: '32px', letterSpacing: '-0.02em' }],
        '4xl':  ['30px', { lineHeight: '36px', letterSpacing: '-0.025em' }],
      },
      borderRadius: {
        none: '0',
        sm:   '4px',
        DEFAULT: '6px',
        md:   '6px',
        lg:   '8px',
        xl:   '12px',
        '2xl': '16px',
        full: '9999px',
      },
      boxShadow: {
        xs:  '0 1px 2px 0 rgba(15, 23, 42, 0.04)',
        sm:  '0 1px 2px 0 rgba(15, 23, 42, 0.05), 0 1px 3px 0 rgba(15, 23, 42, 0.06)',
        md:  '0 2px 4px -1px rgba(15, 23, 42, 0.06), 0 4px 6px -2px rgba(15, 23, 42, 0.04)',
        lg:  '0 4px 6px -2px rgba(15, 23, 42, 0.05), 0 10px 15px -3px rgba(15, 23, 42, 0.08)',
        xl:  '0 8px 10px -4px rgba(15, 23, 42, 0.06), 0 20px 25px -5px rgba(15, 23, 42, 0.10)',
        '2xl': '0 25px 50px -12px rgba(15, 23, 42, 0.18)',
        focus: 'var(--ring-focus)',
        'focus-error': 'var(--ring-error)',
      },
      transitionTimingFunction: {
        out:      'cubic-bezier(0.16, 1, 0.3, 1)',
        'out-back': 'cubic-bezier(0.34, 1.56, 0.64, 1)',
      },
      transitionDuration: {
        75: '75ms', 100: '100ms', 150: '150ms',
        200: '200ms', 250: '250ms', 300: '300ms',
      },
      maxWidth: {
        '8xl': '1536px',
      },
      animation: {
        'dialog-enter':  'dialog-enter 250ms cubic-bezier(0.34, 1.56, 0.64, 1)',
        'popover-enter': 'popover-enter 150ms cubic-bezier(0.16, 1, 0.3, 1)',
        'toast-enter':   'toast-enter 200ms cubic-bezier(0.16, 1, 0.3, 1)',
        shimmer:         'shimmer 1.5s ease-in-out infinite',
      },
      keyframes: {
        'dialog-enter': {
          from: { opacity: '0', transform: 'translateY(8px) scale(0.98)' },
          to:   { opacity: '1', transform: 'translateY(0) scale(1)' },
        },
        'popover-enter': {
          from: { opacity: '0', transform: 'translateY(-4px)' },
          to:   { opacity: '1', transform: 'translateY(0)' },
        },
        'toast-enter': {
          from: { opacity: '0', transform: 'translateX(8px)' },
          to:   { opacity: '1', transform: 'translateX(0)' },
        },
        shimmer: {
          from: { backgroundPosition: '200% 0' },
          to:   { backgroundPosition: '-200% 0' },
        },
      },
    },
  },
  plugins: [],
};

export default config;

Framer Motion Variants

// src/lib/motion.ts
import type { Variants, Transition } from 'framer-motion';

const easeOut: Transition['ease'] = [0.16, 1, 0.3, 1];
const easeOutBack: Transition['ease'] = [0.34, 1.56, 0.64, 1];

/** Modals, dialogs */
export const dialogVariants: Variants = {
  hidden: { opacity: 0, y: 8, scale: 0.98 },
  visible: {
    opacity: 1, y: 0, scale: 1,
    transition: { duration: 0.25, ease: easeOutBack },
  },
  exit: {
    opacity: 0, y: 4, scale: 0.99,
    transition: { duration: 0.15, ease: easeOut },
  },
};

/** Dropdowns, popovers, menus */
export const popoverVariants: Variants = {
  hidden: { opacity: 0, y: -4 },
  visible: { opacity: 1, y: 0, transition: { duration: 0.15, ease: easeOut } },
  exit: { opacity: 0, y: -2, transition: { duration: 0.10, ease: easeOut } },
};

/** Toasts */
export const toastVariants: Variants = {
  hidden: { opacity: 0, x: 16 },
  visible: { opacity: 1, x: 0, transition: { duration: 0.20, ease: easeOut } },
  exit: { opacity: 0, x: 16, transition: { duration: 0.15, ease: easeOut } },
};

/** Slide-over / drawer */
export const sheetVariants: Variants = {
  hidden: { x: '100%' },
  visible: { x: 0, transition: { duration: 0.30, ease: easeOut } },
  exit: { x: '100%', transition: { duration: 0.25, ease: easeOut } },
};

/** Backdrop fade */
export const backdropVariants: Variants = {
  hidden: { opacity: 0 },
  visible: { opacity: 1, transition: { duration: 0.20, ease: 'linear' } },
  exit: { opacity: 0, transition: { duration: 0.15, ease: 'linear' } },
};

/** Optimistic checkmark (action confirmation) */
export const checkmarkVariants: Variants = {
  hidden: { scale: 0, opacity: 0 },
  visible: {
    scale: 1, opacity: 1,
    transition: { duration: 0.20, ease: easeOutBack },
  },
};

/** Accordion expand/collapse */
export const accordionVariants: Variants = {
  collapsed: { height: 0, opacity: 0, transition: { duration: 0.20, ease: easeOut } },
  expanded: { height: 'auto', opacity: 1, transition: { duration: 0.25, ease: easeOut } },
};

Global Keyboard Handler

// src/lib/shortcuts.ts
type Handler = (e: KeyboardEvent) => void;

const handlers = new Map<string, Set<Handler>>();

export function register(combo: string, handler: Handler) {
  if (!handlers.has(combo)) handlers.set(combo, new Set());
  handlers.get(combo)!.add(handler);
  return () => handlers.get(combo)?.delete(handler);
}

function keyOf(e: KeyboardEvent): string {
  const parts: string[] = [];
  if (e.metaKey || e.ctrlKey) parts.push('mod');
  if (e.shiftKey) parts.push('shift');
  if (e.altKey) parts.push('alt');
  parts.push(e.key.toLowerCase());
  return parts.join('+');
}

if (typeof window !== 'undefined') {
  window.addEventListener('keydown', (e) => {
    // Don't trigger global shortcuts inside text fields (except Esc and Mod+K)
    const target = e.target as HTMLElement;
    const inField = target.matches('input, textarea, [contenteditable]');
    const combo = keyOf(e);
    if (inField && combo !== 'escape' && combo !== 'mod+k') return;
    const set = handlers.get(combo);
    if (set?.size) {
      e.preventDefault();
      set.forEach((h) => h(e));
    }
  });
}

Usage:

useEffect(() => register('mod+k', () => setCommandOpen(true)), []);
useEffect(() => register('/', () => searchRef.current?.focus()), []);

16. SaaS Module Patterns

16.1 Application Shell

The canonical layout. Implement once, share across all authenticated pages.

export function AppShell({ children }: { children: React.ReactNode }) {
  return (
    <div className="h-screen flex flex-col bg-canvas text-neutral-900 dark:text-neutral-50">
      <Topbar />
      <div className="flex-1 flex overflow-hidden">
        <Sidebar />
        <main className="flex-1 overflow-y-auto">
          <div className="mx-auto max-w-7xl px-6 lg:px-8 py-6">
            {children}
          </div>
        </main>
      </div>
    </div>
  );
}

Key invariants:

  • The whole viewport is h-screen with overflow-hidden. Only the main content area scrolls.
  • The topbar and sidebar are fixed in place. They stay visible during scroll.
  • The content area has its own scroll context (overflow-y-auto), so sticky table headers work inside.
  • Max content width caps at --container-2xl (1280–1536px) for readability; centered with mx-auto.

16.2 Page Header

Every page in the app starts with a header. Standard shape:

<div className="flex items-start justify-between gap-4 mb-6">
  <div className="min-w-0">
    <Breadcrumb items={[{ label: 'Customers' }, { label: 'Acme Corp' }]} />
    <h1 className="mt-1 text-2xl font-semibold tracking-tight">
      {title}
    </h1>
    {description && (
      <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
        {description}
      </p>
    )}
  </div>
  <div className="flex items-center gap-2 shrink-0">
    <Button variant="secondary" size="sm">Export</Button>
    <Button variant="primary" size="sm">New customer</Button>
  </div>
</div>

Rules:

  • One H1 per page. Sub-sections use H2/H3.
  • Max one primary button in the actions area. Secondary actions can be a chain of secondary/ghost. Overflow → dropdown.
  • Breadcrumb only when 2+ levels deep. A bare page doesn't need a breadcrumb that just says "Customers".

16.3 Data Tables

The most-used surface in SaaS. Specifications:

Row height:        40px (default) / 48px (with avatar)
Cell padding:      0 16px horizontal, vertical centered
Header padding:    same horizontal, 12px vertical
Header background: --bg-canvas (1 step darker than body) — sticks during scroll
Border:            1px between header and body, NO row separators by default
Hover:             row bg → --bg-hover
Selection:         row bg → --brand-bg-subtle, leftmost cell shows brand color stripe (3px wide, only on selected)
Column header:     13px, 500 weight, --text-muted, uppercase optional
Cell text:         13-14px, 400 weight
Numeric columns:   right-aligned, tabular-nums

Column types

TypeAlignmentWrappingSpecial
ID / handleleftnowrapfont-mono, text-neutral-500
Primary nameleftnowrap → truncatefont-medium
Descriptionleftclamp 1-2 linessecondary
Date / timeleftnowrapdata-numeric="true", relative on hover absolute
Amount / moneyrightnowrapfont-mono, tabular-nums
Statusleftnowrap<Badge>
Avatar + nameleftnowrap24px avatar + 8px gap + name
Row actionsrightnone<DropdownMenu> triggered by icon

Empty / loading / error

  • Empty: Show empty state component inside the table body row spanning all columns.
  • Loading (initial): Render 5–10 skeleton rows matching the column structure.
  • Loading (subsequent): Overlay the existing data at 50% opacity with a thin top progress bar.
  • Error: Replace tbody with an error state row (icon, message, retry button).

Pagination

For server-side: bottom-aligned, "Showing 1–25 of 1,247" left, prev/next buttons right. Avoid numbered pagination unless explicitly requested — Prev | 1 2 3 … 50 | Next clutters more than it helps.

For client-side small datasets (<200 rows): no pagination; let it scroll within a max-height container.

16.4 Settings Pages

Three layouts to choose from:

A. Single-column settings (small projects, profile pages):

Page header
─────
Form section heading
  Field row (label left @ 240px, input right)
  Field row
  ─ 1px divider ─
  Field row
Form section heading
  Field row
─────
[Save] (sticky bottom OR inline after fields)

B. Sub-nav settings (most products):

Page header
─────
[Profile] [Team] [Billing] [Integrations] [API keys]   ← tabs OR secondary sidebar
─────
Selected tab's content (using layout A)

C. Full app-within-app (large products):

Sidebar → "Settings"
  Sub-sidebar (256px) ────┬──── Content (single-column form)
   Profile                │
   Account                │
   Notifications          │
   Team                   │
   Billing                │

Each pattern uses the same form row primitive:

<div className="grid grid-cols-[240px_1fr] gap-6 py-5 border-b border-default last:border-b-0">
  <div>
    <label className="text-sm font-medium">Display name</label>
    <p className="mt-1 text-xs text-neutral-500">
      Shown to teammates in mentions and on activity feeds.
    </p>
  </div>
  <div>
    <Input value={name} onChange={setName} maxLength={64} />
    <p className="mt-1.5 text-xs text-neutral-500">{name.length}/64</p>
  </div>
</div>

Save patterns:

  • Per-field autosave (Notion-style): each field debounces and saves on blur. Inline "Saved" toast or checkmark next to the field.
  • Per-section save (Stripe-style): "Save changes" button activates when the form is dirty, lives at section footer.
  • Page-level sticky save bar (Vercel-style): when dirty, a bar slides up from bottom: [Reset] [Save changes].

Pick one per product and use it everywhere. Mixing is jarring.

16.5 Empty States

The empty state is a first-class surface in SaaS. Stripe and Linear use them as activation moments.

Anatomy:

[Single line-art icon, 48px, --neutral-300]
[Heading: declarative — "No customers yet"]
[1-line description: what they'd see when populated + why it's empty]
[Primary action: the obvious next step — "Create customer"]
[Secondary action (optional): docs link, sample data]

Implementation:

<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
  <div className="mb-4 text-neutral-300">
    <UsersIcon size={48} strokeWidth={1.5} />
  </div>
  <h3 className="text-base font-semibold text-neutral-900 dark:text-neutral-50">
    No customers yet
  </h3>
  <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400 max-w-sm">
    Customers appear here after their first successful payment. You can also
    create one manually.
  </p>
  <div className="mt-6 flex items-center gap-2">
    <Button variant="primary" size="sm">Create customer</Button>
    <Button variant="ghost" size="sm" as="a" href="/docs/customers">
      Read the docs →
    </Button>
  </div>
</div>

Variants:

  • First-run empty: full activation messaging with primary action.
  • Filtered empty ("No customers match this search"): just heading + suggestion to clear filters. No primary CTA.
  • Permissioned empty ("You don't have access"): icon + reason + contact-admin link.
  • Error-as-empty: don't use empty state for errors. Use a dedicated error state with retry.

16.6 Authentication

Auth pages are the only place in a SaaS where you allow yourself spacious design — they're conversion-focused, single-task surfaces.

Layout:

┌────────────────────────────────────────────────┐
│                                                │
│            [logo, 32px]                        │   ← Top centered
│                                                │
│            Sign in to Acme                     │   ← 24px / 600
│            Welcome back                        │   ← 14px / muted
│                                                │
│       ┌────────────────────────────┐           │
│       │ [continue with google]     │           │
│       │ [continue with github]     │           │
│       ├────────────────────────────┤           │
│       │ ─── or ───                 │           │
│       ├────────────────────────────┤           │
│       │ Email                      │           │
│       │ [_____________________]    │           │
│       │ Password           [Forgot]│           │
│       │ [_____________________]    │           │
│       │ [        Sign in       ]   │           │
│       └────────────────────────────┘           │
│                                                │
│       Don't have an account? Sign up →         │
│                                                │
└────────────────────────────────────────────────┘

Specs:

  • Card max-width: 400px
  • Card padding: 32px
  • Field gap: 16px
  • Primary button: full-width
  • Social/SSO buttons: above the email/password divider
  • No marketing copy, no "value props" — that's what /pricing is for

16.7 Modals & Dialogs

Use modals sparingly. They block the entire page; only acceptable for:

  1. Destructive confirmation (delete, revoke)
  2. Critical inline forms (≤5 fields, no nested complexity)
  3. Quick previews (image, log line expansion)

For anything bigger, use a slide-over (sheet) that takes the right 40-50% of the viewport.

Anatomy:

┌──────────────────────────────────┐
│  Delete invoice INV-1024     [×] │   ← Header: title + close button
├──────────────────────────────────┤
│                                  │
│  This action cannot be undone.   │
│  The invoice and its payment     │   ← Body: 14px text
│  history will be permanently     │
│  removed.                        │
│                                  │
│  Type INV-1024 to confirm:       │
│  [_________________]             │
│                                  │
├──────────────────────────────────┤
│              [Cancel] [Delete]   │   ← Footer: secondary left, primary right
└──────────────────────────────────┘

Specs:

  • Width: 480px (sm), 560px (default), 720px (lg)
  • Radius: 12px
  • Shadow: --shadow-xl
  • Backdrop: rgba(15, 23, 42, 0.40) with backdrop-filter: blur(2px) (optional)
  • Header padding: 20px 24px
  • Body padding: 20px 24px (no top divider; spacing alone separates)
  • Footer padding: 16px 24px, right-aligned button group, top border
  • Close button: top-right × icon, 32px hit area
  • ESC closes; focus trapped; aria-modal="true"; focus restored to trigger on close

16.8 Slide-Overs / Sheets

For larger inline forms ("New customer", "Edit project"). Slides in from the right.

Specs:

  • Width: 480px (sm), 640px (default), 800px (lg)
  • Full-height
  • Same header/body/footer structure as modal
  • Backdrop optional (Linear: yes; Stripe: no — sheet floats over content)
  • Slide animation: 300ms ease-out
  • ESC closes; click outside dismisses (unless dirty form — confirm first)

16.9 Toasts

Bottom-right corner (default) or top-right. Max 4 stacked; older ones fade.

Anatomy:

┌─────────────────────────────────────┐
│ [icon] Invoice INV-1024 sent. [Undo]│
└─────────────────────────────────────┘

Specs:

  • Width: 320–400px
  • Padding: 12px 16px
  • Radius: 8px
  • Background: --bg-elevated (or status-tinted for error)
  • Shadow: --shadow-lg
  • Border: 1px --border-default
  • Leading icon: 16px status color
  • Auto-dismiss: 5s (success) / 8s (error) / never (with action button)
  • Hover pauses the dismiss timer
  • Action button: link-style, brand color

Variants:

  • success — green check icon, body text only
  • error — red alert icon, expand-on-hover for full message
  • info — blue info icon
  • loading — spinner icon, persistent until completed (replace with success/error)

16.10 Command Palette (⌘K)

The defining "S-tier SaaS" feature. Linear, Vercel, Raycast, Stripe all have one.

Anatomy:

┌─────────────────────────────────────────────────────────────┐
│ 🔍 Type a command or search…                          esc   │
├─────────────────────────────────────────────────────────────┤
│ RECENTLY USED                                                │
│   [→]  Go to customers                          ⌘ G C       │
│   [+]  Create invoice                           ⌘ N I       │
│                                                              │
│ ACTIONS                                                      │
│   [+]  Create customer                          ⌘ N C       │
│   [+]  Create invoice                           ⌘ N I       │
│   [↗]  Export data                                          │
│                                                              │
│ NAVIGATION                                                   │
│   [→]  Dashboard                                ⌘ G D       │
│   [→]  Customers                                ⌘ G C       │
│   [→]  Invoices                                 ⌘ G I       │
│                                                              │
│ HELP                                                         │
│   [?]  Keyboard shortcuts                                   │
│   [↗]  Open documentation                                   │
└─────────────────────────────────────────────────────────────┘
   ↑↓ navigate    ↵ select    esc close

Specs:

  • Width: 640px, max-height: 480px
  • Position: vertically centered, slightly above center (top: 20vh)
  • Backdrop: rgba(15, 23, 42, 0.40) blur 4px
  • Input: 14px, no border, padding 16px, autofocus
  • Result row: 36px tall, 13px text, hover bg + leading icon
  • Selected (keyboard nav): --brand-bg-subtle + --brand-fg text
  • Section headers: 11px uppercase, --text-muted, 12px y-padding
  • Trailing shortcut hints: <kbd> chips, mono 11px
  • Footer (sticky): keyboard legend in --text-subtle

Implementation libs to consider: cmdk (the canonical lib, what Vercel and Linear use).

16.11 Onboarding Checklist

Top of dashboard for first 7-14 days OR until all items complete:

┌──────────────────────────────────────────────────────────┐
│  Get started                                  [×] dismiss │
│  3 of 5 complete                                          │
│  ▓▓▓▓▓▓▓▓▓▓░░░░░░░░  60%                                 │
│                                                           │
│  ✓ Create your workspace                                  │
│  ✓ Invite a teammate                                      │
│  ✓ Connect your first source                              │
│  ○ Create your first chart    →  [Open guide]            │
│  ○ Set up alerts              →  [Open guide]            │
└──────────────────────────────────────────────────────────┘

Specs:

  • Card with --shadow-sm, dismissable
  • Progress bar 4px tall, brand color fill
  • Completed items: subtle (60% opacity), strikethrough text optional
  • Pending items: hover reveals primary action button
  • Dismissable but persist completion state on backend

16.12 Billing / Usage Surfaces

Current plan                    [Manage]
─────
[ Pro plan                                ]
[ $99/mo · renews March 1                 ]
[ Includes: 10 seats, 100k events, 50GB   ]

Usage this period
─────
Events processed     ████████████░░░░  62,341 / 100,000
Storage used         ███░░░░░░░░░░░░░  12.4 GB / 50 GB
Active users         ██████░░░░░░░░░░  6 / 10

Recent invoices                 [Download all]
─────
Mar 1, 2025          $99.00          [Paid]      [PDF]
Feb 1, 2025          $99.00          [Paid]      [PDF]
Jan 1, 2025          $99.00          [Paid]      [PDF]

Specs:

  • Plan card uses --bg-elevated background
  • Usage bars: full-width, 8px tall, --brand-bg-subtle track + --brand-fg fill (or warning color when ≥80%)
  • Invoice list: table with right-aligned amount, status badge, download link

17. Component Snippets

Copy-paste-ready. React + Tailwind by default; vanilla HTML provided where the React version would obscure the structural CSS. All examples assume the tokens.css and tailwind config from Sections 14 / 15.6 are loaded.

17.1 Buttons

BTN-PRIMARY

<button
  className="inline-flex items-center justify-center gap-1.5
             h-8 px-3 rounded-md text-sm font-medium
             bg-brand-600 text-white
             hover:bg-brand-700
             active:bg-brand-800
             focus-visible:outline-none focus-visible:shadow-focus
             disabled:opacity-50 disabled:cursor-not-allowed
             transition-colors duration-150"
>
  Save changes
</button>

BTN-SECONDARY

<button
  className="inline-flex items-center justify-center gap-1.5
             h-8 px-3 rounded-md text-sm font-medium
             bg-surface text-neutral-900 dark:text-neutral-50
             border border-neutral-200 dark:border-neutral-800
             hover:bg-neutral-50 dark:hover:bg-neutral-800/50
             hover:border-neutral-300 dark:hover:border-neutral-700
             active:bg-neutral-100 dark:active:bg-neutral-800
             focus-visible:outline-none focus-visible:shadow-focus
             disabled:opacity-50 disabled:cursor-not-allowed
             transition-colors duration-150"
>
  Cancel
</button>

BTN-GHOST

<button
  className="inline-flex items-center justify-center gap-1.5
             h-8 px-2.5 rounded-md text-sm font-medium
             bg-transparent text-neutral-700 dark:text-neutral-300
             hover:bg-neutral-100 dark:hover:bg-neutral-800/50
             hover:text-neutral-900 dark:hover:text-neutral-50
             focus-visible:outline-none focus-visible:shadow-focus
             disabled:opacity-50 disabled:cursor-not-allowed
             transition-colors duration-150"
>
  Edit
</button>

BTN-DESTRUCTIVE

<button
  className="inline-flex items-center justify-center gap-1.5
             h-8 px-3 rounded-md text-sm font-medium
             bg-red-600 text-white
             hover:bg-red-700
             active:bg-red-800
             focus-visible:outline-none focus-visible:shadow-focus-error
             disabled:opacity-50 disabled:cursor-not-allowed
             transition-colors duration-150"
>
  Delete project
</button>

BTN-ICON (square)

<button
  aria-label="Settings"
  className="inline-flex items-center justify-center
             w-8 h-8 rounded-md
             bg-transparent text-neutral-600 dark:text-neutral-400
             hover:bg-neutral-100 dark:hover:bg-neutral-800/50
             hover:text-neutral-900 dark:hover:text-neutral-50
             focus-visible:outline-none focus-visible:shadow-focus
             transition-colors duration-150"
>
  <SettingsIcon className="w-4 h-4" strokeWidth={1.75} />
</button>

BTN-LOADING

<button
  disabled
  className="inline-flex items-center justify-center gap-2
             h-8 px-3 rounded-md text-sm font-medium
             bg-brand-600 text-white opacity-80 cursor-progress"
>
  <svg className="w-3.5 h-3.5 animate-spin" viewBox="0 0 16 16" fill="none">
    <circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.3" strokeWidth="2" />
    <path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
  </svg>
  Saving…
</button>

BTN-LINK

<a
  href="/docs"
  className="inline-flex items-center gap-1
             text-sm font-medium text-brand-600 dark:text-brand-fg
             hover:text-brand-700 hover:underline underline-offset-4
             focus-visible:outline-none focus-visible:shadow-focus
             rounded-sm transition-colors duration-150"
>
  Read the docs
  <ArrowRightIcon className="w-3.5 h-3.5" />
</a>

BTN-SEGMENTED (radio group)

<div className="inline-flex p-0.5 bg-neutral-100 dark:bg-neutral-800/50 rounded-md gap-0.5">
  {options.map((opt) => (
    <button
      key={opt.value}
      onClick={() => setValue(opt.value)}
      className={cn(
        'px-2.5 h-7 rounded text-sm font-medium transition-colors duration-100',
        value === opt.value
          ? 'bg-surface text-neutral-900 dark:text-neutral-50 shadow-xs'
          : 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900'
      )}
    >
      {opt.label}
    </button>
  ))}
</div>

17.2 Inputs

INPUT-TEXT

<input
  type="text"
  placeholder="customer@example.com"
  className="block w-full h-8 px-3 rounded-md text-sm
             bg-surface text-neutral-900 dark:text-neutral-50
             placeholder:text-neutral-400 dark:placeholder:text-neutral-500
             border border-neutral-200 dark:border-neutral-800
             hover:border-neutral-300 dark:hover:border-neutral-700
             focus:border-brand-600 focus:outline-none focus:shadow-focus
             disabled:opacity-50 disabled:cursor-not-allowed
             transition-colors duration-150"
/>

INPUT-WITH-LABEL-HELP

<div className="space-y-1.5">
  <label htmlFor="email" className="block text-sm font-medium">
    Work email
  </label>
  <input
    id="email"
    type="email"
    aria-describedby="email-help"
    className="block w-full h-8 px-3 rounded-md text-sm
               bg-surface border border-neutral-200 dark:border-neutral-800
               focus:border-brand-600 focus:outline-none focus:shadow-focus"
  />
  <p id="email-help" className="text-xs text-neutral-500">
    We'll use this for receipts and password resets.
  </p>
</div>

INPUT-ERROR-STATE

<div className="space-y-1.5">
  <label htmlFor="email-err" className="block text-sm font-medium">
    Work email
  </label>
  <input
    id="email-err"
    aria-invalid="true"
    aria-describedby="email-err-msg"
    className="block w-full h-8 px-3 rounded-md text-sm
               bg-surface border border-red-500
               focus:outline-none focus:shadow-focus-error"
  />
  <p id="email-err-msg" role="alert" className="text-xs text-red-600 dark:text-red-400">
    Enter a valid email address.
  </p>
</div>

INPUT-WITH-LEADING-ICON

<div className="relative">
  <SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
  <input
    type="search"
    placeholder="Search customers…"
    className="block w-full h-8 pl-8 pr-3 rounded-md text-sm
               bg-surface border border-neutral-200 dark:border-neutral-800
               focus:border-brand-600 focus:outline-none focus:shadow-focus"
  />
  <kbd className="absolute right-2 top-1/2 -translate-y-1/2 hidden sm:inline-flex
                   items-center gap-0.5 px-1.5 h-5 rounded text-[11px] font-mono
                   text-neutral-500 bg-neutral-100 dark:bg-neutral-800
                   border border-neutral-200 dark:border-neutral-700">
    ⌘K
  </kbd>
</div>

TEXTAREA

<textarea
  rows={4}
  placeholder="What's this for?"
  className="block w-full px-3 py-2 rounded-md text-sm
             bg-surface border border-neutral-200 dark:border-neutral-800
             focus:border-brand-600 focus:outline-none focus:shadow-focus
             resize-y min-h-[80px]
             transition-colors duration-150"
/>

SELECT (native)

<div className="relative">
  <select
    className="appearance-none block w-full h-8 pl-3 pr-9 rounded-md text-sm
               bg-surface text-neutral-900 dark:text-neutral-50
               border border-neutral-200 dark:border-neutral-800
               focus:border-brand-600 focus:outline-none focus:shadow-focus
               transition-colors duration-150"
  >
    <option>USD</option>
    <option>EUR</option>
    <option>GBP</option>
  </select>
  <ChevronDownIcon className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
</div>

CHECKBOX

<label className="inline-flex items-center gap-2 cursor-pointer select-none">
  <input
    type="checkbox"
    className="peer sr-only"
    checked={checked}
    onChange={(e) => setChecked(e.target.checked)}
  />
  <span
    className="grid place-items-center w-4 h-4 rounded-[4px]
               bg-surface border border-neutral-300 dark:border-neutral-700
               peer-checked:bg-brand-600 peer-checked:border-brand-600
               peer-focus-visible:shadow-focus
               transition-colors duration-100"
  >
    {checked && (
      <svg className="w-3 h-3 text-white" viewBox="0 0 12 12" fill="none">
        <path d="M2.5 6L5 8.5L9.5 4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
      </svg>
    )}
  </span>
  <span className="text-sm">Send email receipts</span>
</label>

SWITCH (toggle)

<button
  role="switch"
  aria-checked={on}
  onClick={() => setOn(!on)}
  className={cn(
    'relative inline-flex items-center w-9 h-5 rounded-full',
    'transition-colors duration-150',
    'focus-visible:outline-none focus-visible:shadow-focus',
    on ? 'bg-brand-600' : 'bg-neutral-300 dark:bg-neutral-700'
  )}
>
  <span
    className={cn(
      'inline-block w-4 h-4 rounded-full bg-white shadow-sm',
      'transition-transform duration-150',
      on ? 'translate-x-[18px]' : 'translate-x-0.5'
    )}
  />
</button>

RADIO GROUP

<div role="radiogroup" aria-label="Plan" className="space-y-2">
  {plans.map((plan) => (
    <label
      key={plan.id}
      className={cn(
        'flex items-start gap-3 p-3 rounded-md border cursor-pointer',
        'transition-colors duration-100',
        value === plan.id
          ? 'border-brand-600 bg-brand-50/50 dark:bg-brand-fg/10'
          : 'border-neutral-200 dark:border-neutral-800 hover:border-neutral-300'
      )}
    >
      <input
        type="radio"
        name="plan"
        value={plan.id}
        checked={value === plan.id}
        onChange={() => setValue(plan.id)}
        className="peer sr-only"
      />
      <span className={cn(
        'mt-0.5 grid place-items-center w-4 h-4 rounded-full border-2',
        value === plan.id ? 'border-brand-600' : 'border-neutral-300 dark:border-neutral-600'
      )}>
        {value === plan.id && (
          <span className="w-1.5 h-1.5 rounded-full bg-brand-600" />
        )}
      </span>
      <span className="flex-1">
        <span className="block text-sm font-medium">{plan.name}</span>
        <span className="block text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
          {plan.description}
        </span>
      </span>
      <span className="text-sm font-medium tabular-nums">{plan.price}</span>
    </label>
  ))}
</div>

17.3 Form Row (label-left, input-right)

function FormRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
  return (
    <div className="grid grid-cols-1 md:grid-cols-[240px_1fr] gap-2 md:gap-6 py-5
                    border-b border-neutral-200 dark:border-neutral-800 last:border-b-0">
      <div>
        <label className="text-sm font-medium text-neutral-900 dark:text-neutral-50">
          {label}
        </label>
        {hint && (
          <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
            {hint}
          </p>
        )}
      </div>
      <div className="min-w-0">{children}</div>
    </div>
  );
}

// Usage
<form>
  <FormRow label="Display name" hint="Shown to teammates in mentions.">
    <Input value={name} onChange={setName} maxLength={64} />
  </FormRow>
  <FormRow label="Time zone">
    <Select value={tz} onChange={setTz}>{tzOptions}</Select>
  </FormRow>
</form>

17.4 Cards

CARD-DEFAULT

<div className="bg-surface border border-neutral-200 dark:border-neutral-800 rounded-lg p-5">
  <div className="flex items-start justify-between mb-3">
    <div>
      <h3 className="text-base font-semibold">API keys</h3>
      <p className="text-xs text-neutral-500 mt-0.5">
        Used to authenticate requests to your account.
      </p>
    </div>
    <Button variant="secondary" size="sm">Create key</Button>
  </div>
  <div className="text-sm">{/* card body */}</div>
</div>

CARD-KPI (stat tile)

<div className="bg-surface border border-neutral-200 dark:border-neutral-800 rounded-lg p-4">
  <div className="flex items-center justify-between">
    <p className="text-xs font-medium text-neutral-500 uppercase tracking-wider">
      Active subscriptions
    </p>
    <UsersIcon className="w-4 h-4 text-neutral-400" />
  </div>
  <div className="mt-2 flex items-baseline gap-2">
    <span className="text-3xl font-semibold tabular-nums tracking-tight">
      1,247
    </span>
    <span className="text-xs font-medium text-green-600 dark:text-green-400">
      +12.4%
    </span>
  </div>
  <p className="mt-0.5 text-xs text-neutral-500">vs. previous 30 days</p>
</div>

CARD-INTERACTIVE (clickable list item)

<button
  className="group w-full text-left
             bg-surface border border-neutral-200 dark:border-neutral-800 rounded-lg p-4
             hover:border-neutral-300 dark:hover:border-neutral-700 hover:shadow-sm
             focus-visible:outline-none focus-visible:shadow-focus
             transition-all duration-150"
>
  <div className="flex items-center gap-3">
    <Avatar src={project.icon} size={32} />
    <div className="flex-1 min-w-0">
      <div className="text-sm font-medium truncate">{project.name}</div>
      <div className="text-xs text-neutral-500 truncate">{project.url}</div>
    </div>
    <ChevronRightIcon className="w-4 h-4 text-neutral-400 group-hover:text-neutral-600 group-hover:translate-x-0.5 transition-all" />
  </div>
</button>

17.5 Data Table

function DataTable({ rows, columns }: Props) {
  return (
    <div className="bg-surface border border-neutral-200 dark:border-neutral-800 rounded-lg overflow-hidden">
      {/* Toolbar */}
      <div className="flex items-center gap-2 p-3 border-b border-neutral-200 dark:border-neutral-800">
        <div className="relative flex-1 max-w-xs">
          <SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
          <input
            type="search"
            placeholder="Search…"
            className="block w-full h-8 pl-8 pr-3 rounded-md text-sm
                       bg-surface border border-neutral-200 dark:border-neutral-800
                       focus:border-brand-600 focus:outline-none focus:shadow-focus"
          />
        </div>
        <Button variant="secondary" size="sm">
          <FilterIcon className="w-3.5 h-3.5" /> Filter
        </Button>
        <div className="ml-auto flex items-center gap-2">
          <Button variant="ghost" size="sm">Export</Button>
        </div>
      </div>

      {/* Table */}
      <div className="overflow-x-auto">
        <table className="w-full text-sm">
          <thead className="bg-neutral-50 dark:bg-neutral-900/50">
            <tr>
              {columns.map((col) => (
                <th
                  key={col.key}
                  scope="col"
                  className={cn(
                    'px-4 py-2.5 font-medium text-neutral-600 dark:text-neutral-400',
                    col.numeric ? 'text-right' : 'text-left',
                    col.sortable && 'cursor-pointer hover:text-neutral-900'
                  )}
                  onClick={() => col.sortable && toggleSort(col.key)}
                >
                  <span className="inline-flex items-center gap-1">
                    {col.label}
                    {col.sortable && sortKey === col.key && (
                      <ChevronDownIcon className={cn('w-3 h-3', sortDir === 'asc' && 'rotate-180')} />
                    )}
                  </span>
                </th>
              ))}
              <th className="w-8 px-4 py-2.5" /> {/* row action column */}
            </tr>
          </thead>
          <tbody>
            {rows.map((row) => (
              <tr
                key={row.id}
                className="group border-t border-neutral-200 dark:border-neutral-800
                           hover:bg-neutral-50 dark:hover:bg-neutral-900/30
                           transition-colors duration-100"
              >
                {columns.map((col) => (
                  <td
                    key={col.key}
                    className={cn(
                      'px-4 py-2.5',
                      col.numeric && 'text-right tabular-nums font-mono text-xs',
                      col.muted && 'text-neutral-500'
                    )}
                  >
                    {col.render ? col.render(row) : row[col.key]}
                  </td>
                ))}
                <td className="px-2 py-2.5">
                  <button
                    aria-label="Row actions"
                    className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100
                               w-7 h-7 rounded grid place-items-center
                               hover:bg-neutral-200 dark:hover:bg-neutral-800
                               transition-opacity duration-100"
                  >
                    <MoreHorizontalIcon className="w-4 h-4" />
                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {/* Pagination */}
      <div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800 text-xs text-neutral-500">
        <span>Showing 1–25 of <span className="tabular-nums">1,247</span></span>
        <div className="flex items-center gap-1">
          <Button variant="ghost" size="sm" disabled>Previous</Button>
          <Button variant="ghost" size="sm">Next</Button>
        </div>
      </div>
    </div>
  );
}

TABLE-SKELETON-ROW

function TableSkeleton({ rows = 8, cols = 5 }: { rows?: number; cols?: number }) {
  return (
    <tbody>
      {Array.from({ length: rows }).map((_, i) => (
        <tr key={i} className="border-t border-neutral-200 dark:border-neutral-800">
          {Array.from({ length: cols }).map((__, j) => (
            <td key={j} className="px-4 py-3">
              <div
                className="h-4 rounded skeleton"
                style={{ width: `${40 + Math.random() * 50}%` }}
              />
            </td>
          ))}
        </tr>
      ))}
    </tbody>
  );
}

17.6 Navigation

SIDEBAR

function Sidebar() {
  return (
    <aside className="hidden lg:flex flex-col w-64 shrink-0 border-r border-neutral-200 dark:border-neutral-800 bg-canvas">
      {/* Workspace switcher */}
      <button className="flex items-center gap-2 m-2 p-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors">
        <div className="w-6 h-6 rounded bg-brand-600 grid place-items-center text-white text-xs font-semibold">
          A
        </div>
        <span className="flex-1 text-left text-sm font-medium truncate">Acme Corp</span>
        <ChevronsUpDownIcon className="w-3.5 h-3.5 text-neutral-400" />
      </button>

      {/* Nav */}
      <nav className="flex-1 overflow-y-auto px-2 pb-2">
        <SidebarSection label="Workspace">
          <SidebarItem icon={<HomeIcon />} href="/" active>Dashboard</SidebarItem>
          <SidebarItem icon={<UsersIcon />} href="/customers" badge="247">Customers</SidebarItem>
          <SidebarItem icon={<FileTextIcon />} href="/invoices">Invoices</SidebarItem>
        </SidebarSection>

        <SidebarSection label="Settings">
          <SidebarItem icon={<SettingsIcon />} href="/settings">General</SidebarItem>
          <SidebarItem icon={<KeyIcon />} href="/settings/api-keys">API keys</SidebarItem>
        </SidebarSection>
      </nav>

      {/* User menu */}
      <button className="flex items-center gap-2 m-2 p-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors">
        <Avatar size={24} src={user.avatar} />
        <div className="flex-1 text-left min-w-0">
          <div className="text-sm font-medium truncate">{user.name}</div>
          <div className="text-xs text-neutral-500 truncate">{user.email}</div>
        </div>
      </button>
    </aside>
  );
}

function SidebarSection({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <div className="mt-4 first:mt-2">
      <div className="px-2 py-1 text-[11px] font-medium uppercase tracking-wider text-neutral-500">
        {label}
      </div>
      <div className="space-y-0.5">{children}</div>
    </div>
  );
}

function SidebarItem({ icon, href, active, badge, children }: SidebarItemProps) {
  return (
    <a
      href={href}
      className={cn(
        'group flex items-center gap-2 px-2 h-8 rounded-md text-sm font-medium',
        'transition-colors duration-100',
        active
          ? 'bg-brand-50 dark:bg-brand-fg/10 text-brand-700 dark:text-brand-fg'
          : 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800/50 hover:text-neutral-900 dark:hover:text-neutral-50'
      )}
    >
      <span className={cn('shrink-0', active ? 'text-brand-600 dark:text-brand-fg' : 'text-neutral-500')}>
        {React.cloneElement(icon as React.ReactElement, { className: 'w-4 h-4', strokeWidth: 1.75 })}
      </span>
      <span className="flex-1 truncate">{children}</span>
      {badge && (
        <span className="text-xs text-neutral-500 tabular-nums">{badge}</span>
      )}
    </a>
  );
}

TOPBAR

function Topbar() {
  return (
    <header className="h-12 shrink-0 flex items-center gap-2 px-4 border-b border-neutral-200 dark:border-neutral-800 bg-surface">
      <button aria-label="Toggle sidebar" className="lg:hidden p-1.5 rounded hover:bg-neutral-100">
        <MenuIcon className="w-4 h-4" />
      </button>

      <button
        onClick={openCommand}
        className="hidden md:inline-flex items-center gap-2 flex-1 max-w-md
                   h-8 px-3 rounded-md text-sm
                   bg-neutral-50 dark:bg-neutral-900 text-neutral-500
                   border border-neutral-200 dark:border-neutral-800
                   hover:border-neutral-300 transition-colors"
      >
        <SearchIcon className="w-4 h-4" />
        <span>Search or jump to…</span>
        <kbd className="ml-auto px-1.5 h-5 rounded text-[11px] font-mono
                        bg-surface border border-neutral-200 dark:border-neutral-700">⌘K</kbd>
      </button>

      <div className="ml-auto flex items-center gap-1">
        <IconButton aria-label="Notifications"><BellIcon /></IconButton>
        <IconButton aria-label="Help"><HelpCircleIcon /></IconButton>
        <Avatar size={28} src={user.avatar} />
      </div>
    </header>
  );
}

BREADCRUMB

function Breadcrumb({ items }: { items: Array<{ label: string; href?: string }> }) {
  return (
    <nav aria-label="Breadcrumb" className="flex items-center text-xs text-neutral-500">
      {items.map((item, i) => (
        <React.Fragment key={i}>
          {i > 0 && <ChevronRightIcon className="w-3 h-3 mx-1 text-neutral-300" />}
          {item.href && i < items.length - 1 ? (
            <a href={item.href} className="hover:text-neutral-900 dark:hover:text-neutral-50 transition-colors">
              {item.label}
            </a>
          ) : (
            <span className={i === items.length - 1 ? 'text-neutral-900 dark:text-neutral-50 font-medium' : ''}>
              {item.label}
            </span>
          )}
        </React.Fragment>
      ))}
    </nav>
  );
}

TABS

<div role="tablist" className="flex items-center gap-1 border-b border-neutral-200 dark:border-neutral-800">
  {tabs.map((tab) => (
    <button
      key={tab.id}
      role="tab"
      aria-selected={active === tab.id}
      onClick={() => setActive(tab.id)}
      className={cn(
        'relative px-3 h-9 text-sm font-medium',
        'focus-visible:outline-none focus-visible:shadow-focus rounded-t-md',
        'transition-colors duration-150',
        active === tab.id
          ? 'text-neutral-900 dark:text-neutral-50'
          : 'text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
      )}
    >
      {tab.label}
      {tab.count !== undefined && (
        <span className="ml-1.5 text-xs text-neutral-400 tabular-nums">{tab.count}</span>
      )}
      {active === tab.id && (
        <span className="absolute -bottom-px left-0 right-0 h-0.5 bg-brand-600 rounded-t" />
      )}
    </button>
  ))}
</div>

DROPDOWN MENU

// Using Radix UI primitives (recommended) or headlessui
<DropdownMenu>
  <DropdownMenuTrigger asChild>
    <IconButton aria-label="More"><MoreHorizontalIcon /></IconButton>
  </DropdownMenuTrigger>
  <DropdownMenuContent
    align="end"
    className="min-w-[180px] bg-elevated rounded-lg shadow-md
               border border-neutral-200 dark:border-neutral-800 p-1
               animate-popover-enter"
  >
    <DropdownMenuItem className="flex items-center gap-2 px-2 h-8 rounded text-sm
                                  cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800/50
                                  focus:bg-neutral-100 focus:outline-none">
      <EditIcon className="w-3.5 h-3.5 text-neutral-500" />
      Edit
    </DropdownMenuItem>
    <DropdownMenuItem className="flex items-center gap-2 px-2 h-8 rounded text-sm
                                  cursor-pointer hover:bg-neutral-100">
      <CopyIcon className="w-3.5 h-3.5 text-neutral-500" />
      Duplicate
      <kbd className="ml-auto text-[10px] text-neutral-400 font-mono">⌘D</kbd>
    </DropdownMenuItem>
    <DropdownMenuSeparator className="h-px bg-neutral-200 dark:bg-neutral-800 my-1" />
    <DropdownMenuItem className="flex items-center gap-2 px-2 h-8 rounded text-sm
                                  text-red-600 dark:text-red-400
                                  cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/30">
      <Trash2Icon className="w-3.5 h-3.5" />
      Delete
    </DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>

17.7 Modal / Dialog

// Using Radix Dialog or headlessui
<Dialog open={open} onOpenChange={setOpen}>
  <DialogOverlay
    className="fixed inset-0 bg-neutral-900/40 backdrop-blur-[2px]
               data-[state=open]:animate-fade-in"
  />
  <DialogContent
    className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
               w-full max-w-md bg-elevated rounded-xl shadow-xl
               border border-neutral-200 dark:border-neutral-800
               data-[state=open]:animate-dialog-enter"
  >
    <div className="flex items-start justify-between p-5 pb-3">
      <div>
        <DialogTitle className="text-base font-semibold">
          Delete invoice INV-1024?
        </DialogTitle>
        <DialogDescription className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
          This action cannot be undone. The invoice and its payment history will be permanently removed.
        </DialogDescription>
      </div>
      <DialogClose className="-mr-1 -mt-1 p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800">
        <XIcon className="w-4 h-4" />
      </DialogClose>
    </div>

    <div className="px-5 pb-3">
      <label className="block text-sm font-medium mb-1.5">
        Type <code className="font-mono text-xs bg-neutral-100 dark:bg-neutral-800 px-1 py-0.5 rounded">INV-1024</code> to confirm
      </label>
      <Input value={confirm} onChange={setConfirm} autoFocus />
    </div>

    <div className="flex items-center justify-end gap-2 p-4 border-t border-neutral-200 dark:border-neutral-800">
      <Button variant="secondary" size="sm" onClick={() => setOpen(false)}>Cancel</Button>
      <Button variant="destructive" size="sm" disabled={confirm !== 'INV-1024'}>Delete invoice</Button>
    </div>
  </DialogContent>
</Dialog>

SHEET (slide-over)

<Sheet open={open} onOpenChange={setOpen}>
  <SheetOverlay className="fixed inset-0 bg-neutral-900/30 backdrop-blur-[2px]" />
  <SheetContent
    side="right"
    className="fixed right-0 top-0 bottom-0 w-full max-w-xl
               bg-elevated border-l border-neutral-200 dark:border-neutral-800
               shadow-xl flex flex-col
               data-[state=open]:animate-slide-in-right"
  >
    <div className="flex items-center justify-between p-5 border-b border-neutral-200 dark:border-neutral-800">
      <SheetTitle className="text-base font-semibold">New customer</SheetTitle>
      <SheetClose className="p-1.5 rounded hover:bg-neutral-100">
        <XIcon className="w-4 h-4" />
      </SheetClose>
    </div>

    <div className="flex-1 overflow-y-auto p-5 space-y-4">
      {/* form body */}
    </div>

    <div className="flex items-center justify-end gap-2 p-4 border-t border-neutral-200 dark:border-neutral-800">
      <Button variant="secondary" size="sm" onClick={() => setOpen(false)}>Cancel</Button>
      <Button variant="primary" size="sm">Create customer</Button>
    </div>
  </SheetContent>
</Sheet>

17.8 Toast

function Toast({ variant, title, description, action, onDismiss }: ToastProps) {
  const tone = {
    success: { icon: CheckCircleIcon, color: 'text-green-600 dark:text-green-400' },
    error:   { icon: AlertCircleIcon, color: 'text-red-600 dark:text-red-400' },
    info:    { icon: InfoIcon,        color: 'text-blue-600 dark:text-blue-400' },
    loading: { icon: LoaderIcon,      color: 'text-neutral-500 animate-spin' },
  }[variant];

  return (
    <div
      role={variant === 'error' ? 'alert' : 'status'}
      className="flex items-start gap-3 w-[360px] p-3 pr-2
                 bg-elevated rounded-lg shadow-lg
                 border border-neutral-200 dark:border-neutral-800
                 animate-toast-enter"
    >
      <tone.icon className={cn('w-4 h-4 mt-0.5 shrink-0', tone.color)} strokeWidth={1.75} />
      <div className="flex-1 min-w-0">
        <p className="text-sm font-medium">{title}</p>
        {description && (
          <p className="text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
            {description}
          </p>
        )}
        {action && (
          <button
            onClick={action.onClick}
            className="mt-1.5 text-xs font-medium text-brand-600 dark:text-brand-fg hover:underline"
          >
            {action.label}
          </button>
        )}
      </div>
      <button
        onClick={onDismiss}
        aria-label="Dismiss"
        className="shrink-0 p-1 -m-1 rounded text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800"
      >
        <XIcon className="w-3.5 h-3.5" />
      </button>
    </div>
  );
}

Toaster container (fixed position):

<div className="fixed bottom-4 right-4 z-50 flex flex-col-reverse gap-2 pointer-events-none">
  {toasts.map((t) => (
    <div key={t.id} className="pointer-events-auto">
      <Toast {...t} />
    </div>
  ))}
</div>

17.9 Empty State

function EmptyState({
  icon: Icon,
  title,
  description,
  primaryAction,
  secondaryAction,
}: EmptyStateProps) {
  return (
    <div className="flex flex-col items-center justify-center py-16 px-6 text-center">
      <div className="mb-4 text-neutral-300 dark:text-neutral-600">
        <Icon size={48} strokeWidth={1.5} />
      </div>
      <h3 className="text-base font-semibold text-neutral-900 dark:text-neutral-50">
        {title}
      </h3>
      {description && (
        <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400 max-w-sm">
          {description}
        </p>
      )}
      {(primaryAction || secondaryAction) && (
        <div className="mt-6 flex items-center gap-2">
          {primaryAction && (
            <Button variant="primary" size="sm" onClick={primaryAction.onClick}>
              {primaryAction.label}
            </Button>
          )}
          {secondaryAction && (
            <Button variant="ghost" size="sm" onClick={secondaryAction.onClick}>
              {secondaryAction.label}
            </Button>
          )}
        </div>
      )}
    </div>
  );
}

17.10 Loading States

SKELETON

.skeleton {
  display: block;
  background: linear-gradient(
    90deg,
    var(--neutral-100) 25%,
    var(--neutral-200) 50%,
    var(--neutral-100) 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s ease-in-out infinite;
  border-radius: 4px;
}
.dark .skeleton {
  background: linear-gradient(
    90deg,
    rgba(255, 255, 255, 0.04) 25%,
    rgba(255, 255, 255, 0.08) 50%,
    rgba(255, 255, 255, 0.04) 75%
  );
  background-size: 200% 100%;
}
@keyframes shimmer {
  from { background-position: 200% 0; }
  to   { background-position: -200% 0; }
}

Skeleton card:

<div className="bg-surface border border-neutral-200 dark:border-neutral-800 rounded-lg p-5">
  <div className="h-4 w-1/3 skeleton mb-3" />
  <div className="h-3 w-2/3 skeleton mb-1.5" />
  <div className="h-3 w-1/2 skeleton" />
</div>

SPINNER

function Spinner({ size = 16 }: { size?: number }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 16 16"
      fill="none"
      className="animate-spin"
      style={{ animationDuration: '750ms' }}
      aria-hidden="true"
    >
      <circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
      <path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
    </svg>
  );
}

PROGRESS BAR (determinate)

function Progress({ value, max = 100, tone = 'brand' }: ProgressProps) {
  const pct = Math.min(100, Math.max(0, (value / max) * 100));
  const fill =
    tone === 'warning' ? 'bg-yellow-500' :
    tone === 'error'   ? 'bg-red-500' :
    'bg-brand-600';
  return (
    <div
      role="progressbar"
      aria-valuenow={value}
      aria-valuemin={0}
      aria-valuemax={max}
      className="h-1.5 rounded-full bg-neutral-100 dark:bg-neutral-800 overflow-hidden"
    >
      <div
        className={cn('h-full transition-[width] duration-500 ease-out', fill)}
        style={{ width: `${pct}%` }}
      />
    </div>
  );
}

17.11 Badges

function Badge({ tone = 'neutral', dot, children }: BadgeProps) {
  const tones = {
    neutral: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
    brand:   'bg-brand-50 text-brand-700 dark:bg-brand-fg/15 dark:text-brand-fg',
    success: 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-400',
    warning: 'bg-yellow-50 text-yellow-700 dark:bg-yellow-950/40 dark:text-yellow-400',
    error:   'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400',
    info:    'bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400',
  };
  const dotTones = {
    neutral: 'bg-neutral-400',
    brand:   'bg-brand-600',
    success: 'bg-green-500',
    warning: 'bg-yellow-500',
    error:   'bg-red-500',
    info:    'bg-blue-500',
  };
  return (
    <span className={cn(
      'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium',
      tones[tone]
    )}>
      {dot && <span className={cn('w-1.5 h-1.5 rounded-full', dotTones[tone])} />}
      {children}
    </span>
  );
}

// Usage
<Badge tone="success" dot>Active</Badge>
<Badge tone="warning" dot>Trial ending</Badge>
<Badge tone="error" dot>Failed</Badge>

17.12 Tooltip

// Using Radix Tooltip — apply these styles to the content
<TooltipContent
  side="top"
  sideOffset={4}
  className="z-50 px-2 py-1 rounded text-xs font-medium
             bg-neutral-900 text-neutral-50 dark:bg-neutral-50 dark:text-neutral-900
             shadow-md
             data-[state=delayed-open]:animate-popover-enter"
>
  {label}
  {shortcut && (
    <span className="ml-2 text-neutral-400 dark:text-neutral-500 font-mono">{shortcut}</span>
  )}
</TooltipContent>

Delay: 300ms hover before showing. Tooltips fire on focus too.

17.13 Avatar

function Avatar({ src, name, size = 32 }: AvatarProps) {
  const initials = name?.split(' ').map((s) => s[0]).slice(0, 2).join('').toUpperCase();
  return (
    <span
      className="inline-grid place-items-center rounded-full overflow-hidden
                 bg-neutral-200 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300
                 font-medium shrink-0"
      style={{ width: size, height: size, fontSize: Math.round(size * 0.4) }}
    >
      {src ? (
        <img src={src} alt="" className="w-full h-full object-cover" />
      ) : (
        <span>{initials || '?'}</span>
      )}
    </span>
  );
}

// Avatar group with overlap
<div className="flex -space-x-1.5">
  {users.slice(0, 3).map((u) => (
    <Avatar key={u.id} {...u} size={24} className="ring-2 ring-surface" />
  ))}
  {users.length > 3 && (
    <span className="grid place-items-center w-6 h-6 rounded-full
                     bg-neutral-100 dark:bg-neutral-800 ring-2 ring-surface
                     text-[10px] font-medium text-neutral-600">
      +{users.length - 3}
    </span>
  )}
</div>

17.14 KPI Strip

<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
  {kpis.map((kpi) => (
    <div
      key={kpi.label}
      className="bg-surface border border-neutral-200 dark:border-neutral-800 rounded-lg p-4"
    >
      <p className="text-xs font-medium text-neutral-500 uppercase tracking-wider">
        {kpi.label}
      </p>
      <div className="mt-2 flex items-baseline gap-2">
        <span className="text-2xl font-semibold tabular-nums tracking-tight">
          {kpi.value}
        </span>
        {kpi.delta && (
          <span className={cn(
            'text-xs font-medium',
            kpi.delta > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
          )}>
            {kpi.delta > 0 ? '+' : ''}{kpi.delta}%
          </span>
        )}
      </div>
      {kpi.subtitle && (
        <p className="mt-0.5 text-xs text-neutral-500">{kpi.subtitle}</p>
      )}
    </div>
  ))}
</div>

17.15 Pagination

function Pagination({ page, total, perPage, onChange }: PaginationProps) {
  const from = (page - 1) * perPage + 1;
  const to = Math.min(page * perPage, total);
  const lastPage = Math.ceil(total / perPage);
  return (
    <div className="flex items-center justify-between px-4 py-3 text-xs text-neutral-500">
      <span>
        Showing <span className="tabular-nums">{from}–{to}</span> of{' '}
        <span className="tabular-nums">{total.toLocaleString()}</span>
      </span>
      <div className="flex items-center gap-1">
        <Button variant="ghost" size="sm" disabled={page <= 1} onClick={() => onChange(page - 1)}>
          <ChevronLeftIcon className="w-3.5 h-3.5" /> Previous
        </Button>
        <Button variant="ghost" size="sm" disabled={page >= lastPage} onClick={() => onChange(page + 1)}>
          Next <ChevronRightIcon className="w-3.5 h-3.5" />
        </Button>
      </div>
    </div>
  );
}

17.16 Command Palette (using cmdk)

import { Command } from 'cmdk';

function CommandPalette({ open, onOpenChange }: Props) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogOverlay className="fixed inset-0 bg-neutral-900/40 backdrop-blur-[4px]" />
      <DialogContent
        className="fixed left-1/2 top-[20vh] -translate-x-1/2 w-full max-w-xl
                   bg-elevated rounded-xl shadow-xl
                   border border-neutral-200 dark:border-neutral-800
                   data-[state=open]:animate-dialog-enter overflow-hidden"
      >
        <Command className="flex flex-col">
          <div className="flex items-center gap-2 px-4 border-b border-neutral-200 dark:border-neutral-800">
            <SearchIcon className="w-4 h-4 text-neutral-400" />
            <Command.Input
              placeholder="Type a command or search…"
              className="flex-1 h-12 bg-transparent text-sm
                         placeholder:text-neutral-400 focus:outline-none"
            />
            <kbd className="text-[10px] text-neutral-400 px-1.5 py-0.5 rounded
                            bg-neutral-100 dark:bg-neutral-800 font-mono">esc</kbd>
          </div>

          <Command.List className="max-h-[400px] overflow-y-auto p-2">
            <Command.Empty className="py-8 text-center text-sm text-neutral-500">
              No results.
            </Command.Empty>

            <Command.Group heading="Actions" className="cmdk-group">
              <Command.Item className="cmdk-item">
                <PlusIcon className="w-4 h-4 text-neutral-500" />
                <span>Create customer</span>
                <kbd className="ml-auto text-[10px] text-neutral-400 font-mono">⌘ N C</kbd>
              </Command.Item>
              {/* … */}
            </Command.Group>

            <Command.Group heading="Navigation" className="cmdk-group">
              <Command.Item className="cmdk-item">
                <HomeIcon className="w-4 h-4 text-neutral-500" />
                <span>Go to dashboard</span>
                <kbd className="ml-auto text-[10px] text-neutral-400 font-mono">⌘ G D</kbd>
              </Command.Item>
              {/* … */}
            </Command.Group>
          </Command.List>

          <div className="flex items-center justify-between gap-2 px-3 py-2
                          border-t border-neutral-200 dark:border-neutral-800
                          text-[11px] text-neutral-500">
            <div className="flex items-center gap-3">
              <span className="flex items-center gap-1">
                <kbd className="font-mono">↑↓</kbd> navigate
              </span>
              <span className="flex items-center gap-1">
                <kbd className="font-mono">↵</kbd> select
              </span>
            </div>
          </div>
        </Command>
      </DialogContent>
    </Dialog>
  );
}

Required CSS for cmdk items:

.cmdk-group [cmdk-group-heading] {
  padding: 8px;
  font-size: 11px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-subtle);
}
.cmdk-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 8px;
  height: 36px;
  border-radius: 6px;
  font-size: 13px;
  cursor: pointer;
  color: var(--text-primary);
}
.cmdk-item[data-selected="true"] {
  background: var(--brand-bg-subtle);
  color: var(--brand-fg);
}
.cmdk-item[data-selected="true"] svg { color: currentColor; }

17.17 Sticky Save Bar (Vercel-style)

{isDirty && (
  <div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40
                  animate-popover-enter">
    <div className="flex items-center gap-3 pl-4 pr-2 py-2
                    bg-neutral-900 dark:bg-neutral-50 text-neutral-50 dark:text-neutral-900
                    rounded-full shadow-xl">
      <span className="text-sm">You have unsaved changes</span>
      <button
        onClick={onReset}
        className="text-sm text-neutral-400 hover:text-neutral-50 dark:hover:text-neutral-900 transition-colors"
      >
        Reset
      </button>
      <Button variant="primary" size="sm" onClick={onSave}>Save changes</Button>
    </div>
  </div>
)}

18. Pre-flight Checklist

Run through this before shipping any SaaS surface. Each "no" is a regression.

Visual

  • Background is --bg-canvas (not pure white).
  • Text is --text-primary (not pure black).
  • Brand color fills <10% of any viewport (squint test).
  • No pill buttons (border-radius: 9999px) outside avatars and status dots.
  • All borders 1px solid, color --border-default.
  • All shadows are downward, low-spread, neutral (not colored, not glow).
  • One font family for sans, one for mono. No display fonts mixed in.

Density

  • Default UI text is 13px or 14px (not 16px).
  • Default button height is 32px.
  • Default input height matches button height (32px).
  • Table rows are 40px (default) or 32px (compact).
  • Numeric columns use font-variant-numeric: tabular-nums.

Interaction

  • Every interactive element has hover + focus-visible + active states.
  • Focus rings: 2px solid --brand-fg outline OR --ring-focus box-shadow.
  • Hover transitions ≤150ms.
  • Modal/dialog enter ≤250ms.
  • No animation on page mount (no entrance stagger).
  • prefers-reduced-motion honored.

Accessibility

  • Body text contrast ≥4.5:1.
  • UI element contrast (borders, focus rings) ≥3:1.
  • Every form field has a <label> (or aria-label).
  • Errors linked via aria-describedby, role alert.
  • Modals trap focus, restore on close, aria-modal="true".
  • ⌘K, Esc, Tab, ↑↓ work on every surface they apply to.
  • Icon-only buttons have aria-label.

Content

  • One H1 per page.
  • One primary button per surface (the rest are secondary/ghost).
  • Button text is verb + object ("Save changes", not "Submit").
  • Empty states have icon + declarative heading + 1-line description + primary action.
  • Status badges use semantic color + dot, never solid backgrounds.

Performance

  • No CSS-in-JS in hot paths; tokens via CSS variables.
  • Tables virtualize at >100 rows.
  • Skeleton states for any data fetch taking >250ms.
  • No layout shift on initial render (reserve space for async content).

Dark Mode

  • .dark class on <html>, set BEFORE first paint (no FOUC).
  • Three-way theme toggle: system / light / dark.
  • Borders are translucent or lighter in dark mode (not the same as light).
  • Brand color shifts to lighter ramp step in dark mode (--brand-600--brand-fg: #A5B4FC).
  • Shadows nearly invisible in dark mode; elevation conveyed by lifted background.

Last updated: synthesized from Stripe Dashboard, Linear, Vercel, Notion, Clerk, PlanetScale design patterns. Always pin to your product's brand color via the --brand-* scale; everything else stays intact.

More skills from cloudflare