cloudflare/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 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.
npx skills add https://github.com/cloudflare/vibesdk --skill frontend-design-saas
> 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.
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.
| 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. |
#0F172A on #FFFFFF (or #0B1220 on #0A0E1A in dark mode).border-radius: 9999px) for SaaS actions. Pills read as marketing/consumer; SaaS uses 6-8px radius for buttons and inputs.font-variant-numeric: tabular-nums) for any column of numbers.| 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 |
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) |
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 |
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:
text color on bg-subtle background, border for the input.bg-subtle panel, text heading, --neutral-700 body.text on bg-subtle + 1px border. Never use solid bg for badges (too loud).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)
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.
--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:
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.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.| 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.
| 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.
| 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 |
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;
}
--space-unit: 4px;
| 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):
8px 12px (sm), 8px 14px (md), 10px 16px (lg) — vertical padding stays tight8px 12px (sm), 9px 12px (md), 10px 14px (lg)16px (sm), 20-24px (md), 32px (lg / settings panel)10px 16px (compact), 12px 16px (default), 16px 20px (comfortable)24px (mobile) → 32px (desktop) → 48px (wide settings)| 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:
9999px). Pills are for marketing/consumer apps. The exception is a "tag" or "chip" inside a table cell.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.
/* 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);
| 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 |
For most cards, prefer a 1px border over a shadow. Borders are crisper at any DPI and read more "product". Use shadow only when:
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.
/* 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.
| 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.
/* 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; }
}
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.
┌─────────────────────────────────────────────────────────────────┐
│ [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:
⌘K) center or right, user menu on far right. Stays fixed during scroll.Variations:
| 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).
| 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.
/* 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;
}
[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:
--bg-canvas (slightly tinted from page surface) OR --bg-surface flatbg: var(--brand-50), color: var(--brand-700), no border, no left-accent stripe (Linear-style minimal)text-2xs uppercase, --neutral-500, 8px y-padding<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.
: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);
}
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);
}
});
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.--brand-fg becomes #A5B4FC (brand-300/400 range) so it has enough contrast against the dark canvas.--bg-elevated (lighter than canvas) so the artwork doesn't appear floating on void.Minimum ratios:
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 ✓/* 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.
| 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) |
<button>, links use <a href>. Never use a <div onClick> for either.<label> (or aria-label if visually replaced by a placeholder pattern).* marker AND aria-required="true".aria-describedby, role alert for the first error.<th scope="col"> headers, optional <caption> for context.aria-modal="true", role="dialog", labeled by heading.role="status" for non-critical, role="alert" for errors.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 */
}
.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.
SaaS UIs avoid heavy decoration. The few decorative idioms that DO appear:
.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;
}
/* 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; }
}
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>
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.
.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.
| 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).
| 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).
| 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 |
| 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.
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).shell/; shell/ can't import from data/.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;
}
}
> Design Language DNA & Implementation Physics
> Reverse-engineered patterns from Stripe Dashboard, Linear, Vercel, Notion, Clerk, PlanetScale.
| 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.
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
In a SaaS UI, three vertical rhythms coexist:
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.
| 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.
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.
| 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. |
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 |
Buttons — verb + object, never just verb:
Empty state headings — declarative, not interrogative:
Inline help / hints — single sentence, no period (because it's not a sentence in form context):
Destructive confirmation — type-to-confirm for irreversible:
acme-corp to confirm deletion" — see GitHub's repo deletion modal.Toast text — past tense, with optional action:
┌────────────────────────────────────┐
│ [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:
Forbidden:
9999px) — that's marketing┌────────────────────────────────────┐
│ [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.
┌──────────────────────────────────┐
│ 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.
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
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
Page mount animations are bad UX in SaaS:
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?).
// 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;
// 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 } },
};
// 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()), []);
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 />
Take cloudflare/frontend-design-saas from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.