| name | frontend-design-saas |
| description | S-tier SaaS dashboard and product UI reference. Use this skill when building application shells, data tables, settings panels, billing pages, dashboards, auth flows, admin tools, or any internal/customer-facing SaaS product UI. Inspired by Stripe, Linear, Vercel, Airbnb, Notion. Covers neutral-led design tokens, sidebar+content shells, dense data UIs, form-heavy configuration pages, command palettes, empty states, and the accessibility (WCAG AA+) bar these products clear. |
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
| Principle | Description |
|---|
| Users First | Workflows, keyboard navigation, and information density beat decoration. |
| Quiet by Default | Neutrals dominate; the brand color is reserved for primary action, active state, and focus. |
| Speed & Density | Snappy transitions (≤200ms), compact row heights, no entrance animations on every page load. |
| Opinionated Defaults | Single canonical pattern per surface (one button hierarchy, one date picker, one empty state). |
| Meticulous Craft | 1px 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. |
| Predictable | Same 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 build | Look at |
|---|
| Application shell | Linear, Vercel, Stripe Dashboard |
| Data table | Stripe /payments, Linear backlog, PlanetScale branches |
| Settings page | Vercel project settings, Clerk dashboard, GitHub repo settings |
| Billing / invoice | Stripe billing, Vercel usage |
| Empty state | Linear, Notion, Vercel deployments |
| Command palette | Linear ⌘K, Vercel ⌘K, Raycast |
| Auth | Clerk hosted pages, Stripe Connect onboarding |
| Onboarding checklist | Stripe 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.
| Token | Hex | Usage |
|---|
--neutral-0 | #FFFFFF | Page background (light mode), surface lifted onto canvas |
--neutral-50 | #F8FAFC | Canvas background, alternating row stripe |
--neutral-100 | #F1F5F9 | Hover background, subtle fill, table header |
--neutral-200 | #E2E8F0 | Default border, divider |
--neutral-300 | #CBD5E1 | Strong border, input border on focus-within parent |
--neutral-400 | #94A3B8 | Subtle text (placeholders, disabled, captions) |
--neutral-500 | #64748B | Tertiary text, icon default |
--neutral-600 | #475569 | Muted text (descriptions, secondary labels) |
--neutral-700 | #334155 | Strong icon, hover text |
--neutral-800 | #1E293B | Heading in dense UIs, code text |
--neutral-900 | #0F172A | Primary text, primary icons |
--neutral-950 | #020617 | Highest 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.
| Token | Hex | Usage |
|---|
--brand-50 | #EEF0FB | Selected row background, badge background, focus ring background |
--brand-100 | #DDE0F7 | Hover on brand surfaces |
--brand-200 | #B9C0EF | Disabled brand button, decorative |
--brand-500 | #7B85DC | Brand hover surface stroke |
--brand-600 | #5E6AD2 | Primary brand — default button bg, link text, focus ring, active nav item |
--brand-700 | #4A56C0 | Hover state for primary button |
--brand-800 | #3A45A8 | Active/pressed state |
--brand-900 | #2A3284 | High-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.
| State | text | bg (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.
| Token | Light | Dark |
|---|
--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 | #EEF0FB | rgba(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)
| Token | Size | Line Height | Weight | Letter Spacing | Use |
|---|
text-2xs | 11px / 0.6875rem | 1.36 (15px) | 500 | 0.04em | Microlabels (uppercase metadata, table column tags) |
text-xs | 12px / 0.75rem | 1.33 (16px) | 400/500 | 0 | Captions, badge text, table secondary |
text-sm | 13px / 0.8125rem | 1.38 (18px) | 400/500 | 0 | Default UI text: table rows, buttons, form labels |
text-base | 14px / 0.875rem | 1.43 (20px) | 400/500 | 0 | Body text, paragraph copy in panels |
text-md | 15px / 0.9375rem | 1.47 (22px) | 400/500 | 0 | Comfortable reading (descriptions, modals) |
text-lg | 16px / 1rem | 1.5 (24px) | 500/600 | -0.005em | Card headings, sidebar section labels |
text-xl | 18px / 1.125rem | 1.44 (26px) | 600 | -0.01em | Subsection headings |
text-2xl | 20px / 1.25rem | 1.4 (28px) | 600 | -0.015em | Page subheadings |
text-3xl | 24px / 1.5rem | 1.33 (32px) | 600 | -0.02em | Page title (h1 in dashboards) |
text-4xl | 30px / 1.875rem | 1.2 (36px) | 600/700 | -0.025em | Marketing-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
| Weight | Value | Use |
|---|
| Regular | 400 | Body, table cells, descriptions |
| Medium | 500 | Default for buttons, labels, table headers, navigation, badge text |
| Semibold | 600 | Headings, emphasized stats, active nav |
| Bold | 700 | Rare — only marketing-adjacent surfaces or KPI numbers |
Never use weight 800 or 900 in a SaaS UI.
3.4 Letter Spacing
| Context | Value |
|---|
| Body / table | 0 |
| Headings ≥18px | -0.01em to -0.025em (tighter as size grows) |
| Microlabels / uppercase | 0.04em to 0.06em |
| Mono | 0 |
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
| Token | px | rem | Common Use |
|---|
space-0 | 0 | 0 | Reset |
space-px | 1 | — | Hairlines, dividers |
space-0.5 | 2 | 0.125 | Tight icon-to-text gaps |
space-1 | 4 | 0.25 | Inline tight |
space-1.5 | 6 | 0.375 | — |
space-2 | 8 | 0.5 | Default tight gap, icon-text, badge padding |
space-2.5 | 10 | 0.625 | Input vertical padding (compact) |
space-3 | 12 | 0.75 | Input vertical padding (default), small card |
space-4 | 16 | 1 | Default card/panel padding, form field gaps |
space-5 | 20 | 1.25 | Section subheading gap |
space-6 | 24 | 1.5 | Card padding (comfortable), section internal |
space-8 | 32 | 2 | Page section gap, large card padding |
space-10 | 40 | 2.5 | Major section gap |
space-12 | 48 | 3 | Page-level section gap |
space-16 | 64 | 4 | Hero, 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
| Token | Value | Use |
|---|
--radius-none | 0 | Table cells, full-bleed sections |
--radius-sm | 4px | Badges, tags, small chips |
--radius-md | 6px | Default: buttons, inputs, dropdowns, menu items |
--radius-lg | 8px | Cards, panels, popovers |
--radius-xl | 12px | Modals, large surfaces, sheets |
--radius-2xl | 16px | Marketing-adjacent (pricing card, onboarding) |
--radius-full | 9999px | Avatars, 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
--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);
--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
| Layer | Shadow | Examples |
|---|
| 0 (flat on canvas) | none | Sidebar, page body, inline rows |
| 1 (resting card) | --shadow-xs or none + 1px border | Default card on --bg-canvas |
| 2 (hovered card) | --shadow-sm | Card with :hover state |
| 3 (dropdown, popover) | --shadow-md | Menu, popover, select dropdown |
| 4 (modal, dialog) | --shadow-xl | Dialog, slide-over |
| 5 (toast over modal) | --shadow-2xl | Toast, 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
--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);
--ease-linear: linear;
Do NOT use ease, ease-in, or unspecified defaults. They feel sluggish in SaaS contexts.
7.3 Duration Scale
| Token | ms | Use |
|---|
--duration-75 | 75 | Color change, opacity flip (button press color) |
--duration-100 | 100 | Hover background fill |
--duration-150 | 150 | Default — most transitions |
--duration-200 | 200 | Dropdown/popover enter |
--duration-250 | 250 | Modal/dialog enter |
--duration-300 | 300 | Slide-over (drawer) enter |
--duration-500 | 500 | Skeleton 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
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);
}
.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-enter { animation: popover-enter 150ms var(--ease-out); }
@keyframes popover-enter {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.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 {
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:
- Topbar (48–56px): workspace/team switcher on the left, global search (
⌘K) center or right, user menu on far right. Stays fixed during scroll.
- Sidebar (240–280px): primary nav. Collapsible to a 56px icon rail on narrow screens. Stays fixed during scroll.
- 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
| Token | px | Use |
|---|
--container-sm | 640 | Single-column forms, auth, dialogs |
--container-md | 768 | Settings forms, account pages |
--container-lg | 1024 | Dashboard pages, default content max |
--container-xl | 1280 | Wide dashboards with sidebars-in-content |
--container-2xl | 1536 | Data tables, monitoring views (full-bleed) |
Tables get --container-2xl or full-bleed. Settings forms get --container-md (max ~720px reading column).
8.3 Breakpoints
| Name | min-width | Behavior |
|---|
sm | 640px | Form inputs reach full width on phones |
md | 768px | Two-column form layouts kick in |
lg | 1024px | Sidebar appears (below this, sidebar becomes a sheet/drawer) |
xl | 1280px | Sidebar stays expanded; data tables show all columns |
2xl | 1536px | Multi-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 {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.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 {
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 {
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 ) {
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
*:focus { outline: none; }
*:focus-visible {
outline: 2px solid var(--brand-fg);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
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)
| Key | Action |
|---|
⌘K / Ctrl+K | Open command palette |
/ | Focus the page search input |
g then d | Go to dashboard (vim-style nav, optional but Linear/GitHub do it) |
? | Show keyboard shortcut cheat sheet |
Esc | Close modal, dismiss popover, blur input |
Tab / Shift+Tab | Move focus forward/back |
Enter | Activate focused button/link; submit form when input focused |
Space | Toggle checkbox/switch; activate button |
↑ / ↓ | Navigate list items, table rows, menu items |
j / k | Same 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;
}
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;
}
.divider-v {
width: 1px;
background: var(--border-default);
align-self: stretch;
margin: 0 8px;
}
11.2 Inline Pills / Status Dots
.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; }
.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);">
</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;
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
| Variant | Background | Text | Border | Use |
|---|
primary | --brand-600 | #FFFFFF | none | The one primary action per surface |
secondary | --bg-surface | --text-primary | --border-default 1px | Most actions ("Cancel", "Save", "Edit") |
ghost | transparent | --text-primary | none | Toolbar buttons, table row actions |
destructive | #DC2626 | #FFFFFF | none | Delete, revoke, irreversible |
destructive-outline | --bg-surface | #B91C1C | #FCA5A5 | Less aggressive destructive |
link | none | --brand-600 | none, underline on hover | Inline navigation actions |
Sizes: sm (28px tall, 12px text), md (32px tall, 13px text — default), lg (40px tall, 14px text).
Inputs
| State | Border | Background | Shadow |
|---|
| default | --border-default | --bg-surface | none |
| hover | --border-strong | --bg-surface | none |
| focus | --brand-fg | --bg-surface | --ring-focus |
| error | #DC2626 | --bg-surface | --ring-error |
| disabled | --border-default | --neutral-50 | none, opacity: 0.6 |
Sizes match buttons: sm (28px), md (32px default), lg (40px).
Cards
| Variant | Background | Border | Padding | Shadow |
|---|
| default | --bg-surface | 1px --border-default | 20px | none |
| interactive | --bg-surface | 1px --border-default | 20px | hover: --shadow-sm |
| ghost | none | none | 0 | none — for grouping without enclosure |
| elevated | --bg-elevated | none | 24px | --shadow-md |
| outline-only | none | 1px --border-default | 16px | none — for placeholder/empty slots |
Badges / Status Pills
| Variant | Background | Text | Border |
|---|
| neutral | --neutral-100 | --neutral-700 | none |
| brand | --brand-50 | --brand-700 | none |
| success | #DCFCE7 | #15803D | none |
| warning | #FEF9C3 | #A16207 | none |
| error | #FEE2E2 | #B91C1C | none |
| info | #DBEAFE | #1D4ED8 | none |
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-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);
--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;
--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);
--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-1: #2563EB;
--chart-2: #16A34A;
--chart-3: #CA8A04;
--chart-4: #DC2626;
--chart-5: #9333EA;
--chart-6: #0891B2;
--chart-7: #DB2777;
--chart-8: #65A30D;
--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;
--space-unit: 4px;
--radius-none: 0;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-full: 9999px;
--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);
--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;
--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);
}
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
| Tier | Row Height | Button Height | Default Text | When |
|---|
| Compact (Linear, dev tools) | 32px | 28px | 13px | Power-user surfaces, data-heavy tables |
| Default (Stripe, Vercel) | 40px | 32px | 14px | The default SaaS product UI |
| Comfortable (Notion, Clerk) | 48px | 36px | 15px | Content-heavy, settings, billing |
| Spacious (Auth, onboarding) | 56px+ | 40px+ | 16px | Single-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:
- Page rhythm: 24px / 32px / 48px between page sections.
- Component rhythm: 12px / 16px / 20px inside cards and panels.
- 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)
| Layer | Light Mode | Dark 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
| State | Convention |
|---|
| Hover | Background 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. |
| Disabled | opacity: 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.
| Element | Size | Weight |
|---|
| H1 (page title) | 24px | 600 |
| H2 (section) | 18px | 600 |
| H3 (card heading) | 15-16px | 600 |
| Body / table cell | 13-14px | 400 |
| Label / button | 13-14px | 500 |
| Microlabel / caption | 11-12px | 500 (uppercase optional) |
| Mono (id, code, money) | 12-13px | 400 |
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
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
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];
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 },
},
};
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 } },
};
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 } },
};
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 } },
};
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' } },
};
export const checkmarkVariants: Variants = {
hidden: { scale: 0, opacity: 0 },
visible: {
scale: 1, opacity: 1,
transition: { duration: 0.20, ease: easeOutBack },
},
};
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
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) => {
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
| Type | Alignment | Wrapping | Special |
|---|
| ID / handle | left | nowrap | font-mono, text-neutral-500 |
| Primary name | left | nowrap → truncate | font-medium |
| Description | left | clamp 1-2 lines | secondary |
| Date / time | left | nowrap | data-numeric="true", relative on hover absolute |
| Amount / money | right | nowrap | font-mono, tabular-nums |
| Status | left | nowrap | <Badge> |
| Avatar + name | left | nowrap | 24px avatar + 8px gap + name |
| Row actions | right | none | <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:
- Destructive confirmation (delete, revoke)
- Critical inline forms (≤5 fields, no nested complexity)
- 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">