Midnight Drums
Sample Kit · 45 files
$29The principles, conventions, and contribution rules that keep this system consistent as the team scales.
| Change type | When to use | Required checks | Owner / PR tag |
|---|---|---|---|
| Token mapping change | Theme/rebrand updates, contrast fixes, density changes | Update token map, run visual spot-check, confirm contrast for impacted surfaces | @ds-owner |
| New component | New repeated UI pattern used in product | Docs (Why/When/How), accessibility check, tokens only, usage example | @ds-owner |
| Component change | Behavior or visual change to an existing component | Before/after screenshots, accessibility check if interactive, changelog entry | @ds-owner |
| Exception | Time-boxed experiment or urgent delivery that can't wait | Add @ds-exception tag + ticket link + expiry (date or release), document why |
@ds-exception |
Today the system owner is one person. The "Owner / PR tag" column routes PRs to the right reviewer and supports adding more reviewers later.
@ds-exception must include an expiry (date or release). No expiry blocks the PR.@ds-candidate label in the PR.@ds-exception with an expiry.Token-first changes
Why: Tokens create a single source of truth. Changes flow through the system, ensuring consistency and reducing manual updates.Untracked forks
Why: Untracked forks create maintenance burden and visual drift. Future system updates will miss them, causing inconsistency.A single-line field that captures short text values such as names, emails, and URLs.
Baseline single-line input. Set type, inputmode, and autocomplete for the data you expect.
<label for="name" class="text-sm font-medium text-white">Full name</label>
<input
id="name"
type="text"
autocomplete="name"
class="h-11 px-3 w-full rounded-xl bg-zinc-800 border border-zinc-700 text-white text-sm placeholder:text-zinc-500 focus:border-cyan-400 focus:ring-2 focus:ring-cyan-400/[.15] outline-none transition-colors"
placeholder="Enter your name"
/>
<!-- text-sm (14px) = --text-base token. h-11 = --control-height-md (44px) -->
Always provide a <label> linked via for/id so clicking the label focuses the input.
<div class="flex flex-col gap-2">
<label for="email" class="text-sm font-medium text-white">Email address</label>
<input
id="email"
type="email"
autocomplete="email"
class="h-11 px-3 rounded-xl bg-zinc-800 border border-zinc-700 text-white text-sm placeholder:text-zinc-500 focus:border-cyan-400 focus:ring-2 focus:ring-cyan-400/[.15] outline-none"
placeholder="you@example.com"
/>
</div>
<!-- h-11 = --control-height-md (44px) -->
<!-- rounded-xl = --radius-lg (12px) -->
Use helper text for format, constraints, or reassurance. Keep it short and do not repeat the label.
<div class="flex flex-col gap-2">
<label for="store-url" class="text-sm font-medium text-white">Store URL</label>
<input
id="store-url"
type="text"
aria-describedby="store-hint"
class="h-11 px-3 rounded-xl bg-zinc-800 border border-zinc-700 text-white text-sm placeholder:text-zinc-500 focus:border-cyan-400 focus:ring-2 focus:ring-cyan-400/[.15] outline-none"
placeholder="my-store"
/>
<span id="store-hint" class="text-[13px] text-zinc-500">Letters, numbers, and hyphens only</span>
</div>
Use a prefix for fixed context like a domain or currency. Store only the user-entered portion, not the prefix.
<div class="flex flex-col gap-2">
<label for="prefix-store" class="text-sm font-medium text-white">Store URL</label>
<div class="flex">
<span class="h-11 px-3 flex items-center text-sm text-zinc-500 bg-zinc-900 border border-zinc-700 border-r-0 rounded-l-xl">
beatconnect.com/
</span>
<input
id="prefix-store"
type="text"
class="h-11 px-3 flex-1 rounded-r-xl bg-zinc-800 border border-zinc-700 text-white text-sm focus:border-cyan-400 outline-none"
placeholder="my-store"
/>
</div>
</div>
Show after validation (on blur or submit). Mark the input aria-invalid and connect the message via aria-describedby.
<div class="flex flex-col gap-2">
<label for="email" class="text-sm font-medium text-white">Email address</label>
<input
id="email"
type="email"
autocomplete="email"
aria-invalid="true"
aria-describedby="email-error"
class="h-11 px-3 rounded-xl bg-zinc-800 border border-red-500 text-white text-sm focus:ring-2 focus:ring-red-500/[.15] outline-none"
value="invalid-email"
/>
<span id="email-error" role="alert" class="text-[13px] text-red-500">Please enter a valid email address</span>
</div>
Hover, focus-visible, error, and disabled should be visually distinct. The focus ring must remain visible for keyboard users.
A complete input control includes a visible label, the field, and optional helper or error text tied via aria-describedby.
Specs reference tokens so theme and density changes stay centralized and predictable.
| Property | Token | Value |
|---|---|---|
| Height | --control-height-md | 44px |
| Padding | --space-3 | 0 12px |
| Border radius | --radius-lg | 12px |
| Font size | --text-base | 14px |
| Background | --color-bg-surface | #1A1A1A |
| Border | --color-border-subtle | #333333 |
| Border (focus) | --color-primary | #6BDDFF |
| Border (error) | --color-error | #EF4444 |
| Label font size | --text-base | 14px |
| Label gap | --space-2 | 8px |
| Helper text size | --text-sm | 13px |
Visible label required
Why: Linkedfor/id labels let users click the label to focus the field and give screen readers context.Placeholder-only labels
Why: Placeholders disappear when users type, removing the only hint of what the field expects.Actionable error messages
Why: Explain what is wrong and how to fix it so users can recover without guessing.Error styling without a message
Why: A red border alone does not tell users what went wrong or how to fix it.Prefix for fixed values
Why: Prefixes keep the editable part short and prevent typos in fixed portions.Editable fixed text
Why: Forcing users to type domains, country codes, or units increases errors and frustration.The BeatConnect wordmark and icon that identify the brand across product, marketing, and partner touchpoints.
Three variants for different contexts. Use currentColor for the transparent mark so it inherits text color automatically.
<svg width="64" height="64" viewBox="0 0 630 630" fill="none"
xmlns="http://www.w3.org/2000/svg" role="img" aria-label="BeatConnect">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M369.483 125C380.338 125 389.138 133.8 389.138 144.655V164.078L389.14 164.08C389.14 164.157 389.138 164.233 389.138 164.31V177.414C389.138 191.887 400.871 203.621 415.345 203.621H428.448C428.525 203.621 428.601 203.618 428.678 203.617L428.681 203.621H448.103C458.959 203.621 467.759 212.421 467.759 223.276V249.483C467.759 260.338 458.959 269.138 448.103 269.138H415.345C400.871 269.138 389.138 280.871 389.138 295.345V334.655C389.138 349.129 400.871 360.862 415.345 360.862H448.103C458.959 360.862 467.759 369.662 467.759 380.517V406.724C467.759 417.579 458.959 426.379 448.103 426.379H430.889L430.784 426.484C430.014 426.416 429.235 426.379 428.448 426.379H415.345C400.871 426.379 389.138 438.113 389.138 452.586V465.69C389.138 466.477 389.175 467.256 389.243 468.025L389.138 468.131V485.345C389.138 496.2 380.338 505 369.483 505H261.379C250.524 505 241.724 496.2 241.724 485.345V452.586C241.724 438.113 229.991 426.379 215.517 426.379H182.759C171.903 426.379 163.104 417.579 163.104 406.724V223.276C163.104 212.421 171.903 203.621 182.759 203.621H215.517C229.991 203.621 241.724 191.887 241.724 177.414V144.655C241.724 133.8 250.524 125 261.379 125H369.483ZM254.828 190.517C240.354 190.517 228.621 202.25 228.621 216.724V413.276C228.621 427.75 240.354 439.483 254.828 439.483H369.483C370.118 439.483 370.747 439.514 371.367 439.573L371.457 439.483H376.035C390.508 439.483 402.241 427.75 402.241 413.276V408.699L402.332 408.608C402.273 407.988 402.241 407.36 402.241 406.724V373.966C402.241 359.492 390.508 347.759 376.035 347.759H340C329.145 347.759 320.345 338.959 320.345 328.103V301.897C320.345 291.041 329.145 282.241 340 282.241H376.035C390.508 282.241 402.241 270.508 402.241 256.034V216.724C402.241 202.25 390.508 190.517 376.035 190.517H254.828Z"
fill="currentColor"/>
</svg>
| Spec | Rule | Default token |
|---|---|---|
| Minimum size (UI) | 20px mark. Recommended 24px. | n/a |
| Minimum size (favicon) | 16px | n/a |
| Clear space | Clear space = 1/6 of the rendered mark size (minimum: var(--space-2)). | --space-2 minimum |
| Color | Prefer currentColor. Ensure contrast on backgrounds. | n/a |
| Container usage | Only when required by platform (avatars, app icons). | --radius-lg |
Correct usage
Why: The SVG is optimized and uses currentColor, so it adapts to any background automatically.Misuse
Why: Distorting the logo damages brand recognition and reduces legibility.How to render the mark in product UI.
<a class="brand" href="#">
<span class="logo-tile">
<svg width="44" height="44" viewBox="0 0 630 630" ...>...</svg>
</span>
<span class="brand-name">BeatConnect</span>
</a>
<link rel="icon" type="image/svg+xml" href="/mark.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
.brand {
display: flex;
align-items: center;
gap: var(--space-3);
text-decoration: none;
color: inherit;
}
.brand-name {
font-size: var(--text-lg);
font-weight: 600;
}
The token and component layering model that keeps UI decisions flexible while preserving stable component APIs.
Build components against semantic tokens. The tiers below keep brand changes and accessibility fixes confined to token mapping, not component code.
--color-text describes what it's for, not what color it is. This is what components consume.
In this demo, --color-*, --space-*, and --radius-* are the semantic layer. In production, keep semantic names stable and map primitives to semantics per theme or brand scope. Components should never reference primitives or hardcoded values.
:root {
/* Primitives (internal; never used directly in components) */
--c-cyan-500: #6BDDFF;
--c-gray-950: #0A0A0A;
--c-gray-900: #111111;
--c-gray-100: #F5F5F5;
/* Semantic tokens (the contract components should use) */
--color-primary: var(--c-cyan-500);
--color-bg-page: var(--c-gray-950);
--color-bg-surface: var(--c-gray-900);
--color-text: var(--c-gray-100);
/* Component tokens (optional aliases; only when a component needs its own knobs) */
--button-bg: var(--color-primary);
--button-text: var(--color-bg-page);
}
--button-bg) or multiple semantic inputs.--c-cyan-500: #6BDDFF;
--color-primary: var(--c-cyan-500);
--c-blue-500: #3B82F6;
--color-primary: var(--c-blue-500);
Components don't change. Only the token map changes.
.card {
background: var(--color-bg-surface);
color: var(--color-text);
}
.card { background: #111111; } /* hardcoded */
.card { background: var(--c-gray-900); } /* primitive in component */
Token changes follow the approval matrix in Governance. If you're changing token mappings or adding a new token tier, start there.
For the approval process and exception rules, see Governance.
A semantic color system that assigns meaning to surfaces, text, borders, and status so UI remains consistent across themes.
Use these semantic tokens as the source of truth for any fill, stroke, or text color. If a color role feels missing, request a token instead of introducing a one-off hex.
| Swatch | Token | Value | Role | Typical usage |
|---|---|---|---|---|
| Brand and interaction | ||||
--color-primary |
#6BDDFF |
Accent + interaction | Primary actions, focus ring, selected/active states | |
--color-primary-hover |
#8EE5FF |
Accent hover | Hover state for primary actions | |
--color-primary-muted |
#6BDDFF26 |
Accent muted | Subtle highlights, selected rows, tinted backgrounds | |
| Surfaces | ||||
--color-bg-page |
#0A0A0A |
Page background | App canvas | |
--color-bg-surface |
#1A1A1A |
Default surface | Cards, panels, inputs | |
--color-bg-elevated |
#111111 |
Anchored surface | Darker than surface by design. Use for anchored UI (sidebar, sticky headers), not layered depth. | |
--color-bg-surface-hover |
#222222 |
Surface hover | Hover state for clickable surfaces | |
--color-bg-disabled |
#1F1F1F |
Disabled surface | Disabled inputs, buttons, controls | |
| Borders | ||||
--color-border |
#2A2A2A |
Borders + dividers | Table rules, separators, decorative outlines | |
--color-border-subtle |
#333333 |
Subtle border | Hairlines on elevated surfaces, low-contrast separators | |
--color-border-interactive |
#555555 |
Interactive boundary | Input, select, textarea default borders. Must meet WCAG 1.4.11 (≥ 3:1 on surface). | |
| Text | ||||
--color-text |
#EDEDED |
Primary text | Titles and body text on dark surfaces | |
--color-text-muted |
#A1A1A1 |
Secondary text | Helper text, metadata, timestamps | |
--color-text-dim |
#888888 |
Tertiary text | Placeholder, de-emphasis | |
--color-text-disabled |
#555555 |
Disabled text | Disabled labels, placeholder in disabled inputs | |
| Interaction | ||||
--color-focus-ring |
var(--color-primary) |
Focus indicator | Keyboard focus ring on interactive elements | |
--color-overlay |
#0000008C |
Backdrop overlay | Modal/dialog backdrop, drawer scrim | |
| Status and accents | ||||
--color-success |
#22C55E |
Success | Form validation, badges, alerts, toasts | |
--color-warning |
#F59E0B |
Warning | Caution states, pending actions, near-limit indicators | |
--color-error |
#EF4444 |
Error | Validation errors, destructive actions, failed states | |
--color-purple |
#A855F7 |
Secondary accent | Charts, data highlights, optional emphasis | |
Contrast ratios measured on the default background (#1A1A1A). Use this table to pick the right text or border token with confidence.
| Pair | Contrast | Status |
|---|---|---|
--color-text on --color-bg-surface |
12.7:1 |
AAA |
--color-text-muted on --color-bg-surface |
6.6:1 |
AA |
--color-text-dim on --color-bg-surface |
4.4:1 |
AA large |
--color-border-interactive on --color-bg-surface |
3.1:1 |
1.4.11 ✓ |
--color-border on --color-bg-surface |
1.3:1 |
Decorative only |
.card {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.card:hover {
background: var(--color-bg-surface-hover);
}
Font stacks and a type scale that create readable hierarchy across dense interfaces and marketing pages.
| Token | Stack | Use |
|---|---|---|
--font-sans |
'Inter', system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif |
All UI text: headings, body, labels, buttons |
--font-mono |
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace |
Code, tokens, IDs, numeric readouts |
Inter loads from Google Fonts. If unavailable, the browser uses its default system font.
| Token | Value | Typical usage |
|---|---|---|
--text-3xl |
32px |
Hero / display |
--text-2xl |
24px |
Page headings |
--text-xl |
18px |
Section headings |
--text-lg |
16px |
Subheads, prominent labels |
--text-base |
14px |
Body copy, default UI text, buttons |
--text-sm |
13px |
Helper text, secondary labels |
--text-xs |
12px |
Captions, metadata, dense UI labels |
| Token | Value | Typical usage |
|---|---|---|
--font-normal |
400 |
Body text, descriptions |
--font-medium |
500 |
Buttons, labels, table headers |
--font-semibold |
600 |
Subheads, card titles, emphasis |
--font-bold |
700 |
Page headings, display text |
A compact ramp for creator tooling. Prefer weight and color for hierarchy; increase size only when layout needs it. Avoid adding new sizes. If a size is missing, propose a token.
--font-mono for IDs, hashes, timestamps, and numeric readouts. Avoid mono for long sentences./* Single-line truncation (labels, tabs, dense lists) */
.truncate-1 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Two-line clamp (descriptions, cards, secondary content) */
.clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.section-title {
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--color-text-dim);
}
A fixed spacing scale that standardizes padding, margins, and gaps to keep layout density consistent.
Spacing uses two layers: semantic layout tokens (--layout-*) for consistent padding and gaps, mapped to core primitives (--space-*).
Use --layout-* in layout code so global spacing changes are a single remap, not dozens of edits.
Responsive spacing is handled by remapping semantic --layout-* tokens at breakpoints (not by overriding component styles). See Breakpoints for the exact remap values.
The layout contract. Remap these to change rhythm globally without editing components.
| Token | Maps to | Typical use |
|---|---|---|
--layout-page-padding-x |
--space-10 (40px) |
Horizontal page/container padding |
--layout-page-padding-y |
--space-8 (32px) |
Vertical page/container padding |
--layout-page-padding-mobile |
--space-4 (16px) |
Mobile page padding |
--layout-grid-gutter |
--space-6 (24px) |
Grid gaps between cards and columns |
--layout-grid-margin |
--space-10 (40px) |
Outer margin around grids |
--layout-section-gap |
--space-12 (48px) |
Space between major page sections |
--layout-stack-gap |
--space-4 (16px) |
Vertical rhythm in stacks and forms |
--layout-inline-gap |
--space-3 (12px) |
Inline spacing (icon + label, chips) |
:root {
--layout-page-padding-x: var(--space-10);
--layout-page-padding-y: var(--space-8);
--layout-page-padding-mobile: var(--space-4);
--layout-grid-gutter: var(--space-6);
--layout-grid-margin: var(--space-10);
--layout-section-gap: var(--space-12);
--layout-stack-gap: var(--space-4);
--layout-inline-gap: var(--space-3);
}
Primitive lookup. Use inside component internals or as the base for semantic tokens.
| Token | px | Notes |
|---|---|---|
--space-1 |
4 |
Icon gaps, tight pill padding |
--space-2 |
8 |
Dense controls, compact lists |
--space-3 |
12 |
Input vertical padding, small gaps |
--space-4 |
16 |
Card padding, button padding |
--space-5 |
20 |
Compact section padding |
--space-6 |
24 |
Default gutter, content spacing |
--space-8 |
32 |
Large component padding, roomy containers |
--space-10 |
40 |
Generous padding for spacious containers |
--space-12 |
48 |
Major section separation |
Above 24px the scale uses larger steps (skips 7, 9, 11 intentionally). Do not introduce intermediate values.
.page {
padding: var(--layout-page-padding-y) var(--layout-page-padding-x);
}
.grid {
display: grid;
gap: var(--layout-grid-gutter);
}
.stack {
display: flex;
flex-direction: column;
gap: var(--layout-stack-gap);
}
/* Before */
:root { --layout-grid-gutter: var(--space-6); /* 24px */ }
/* After */
:root { --layout-grid-gutter: var(--space-8); /* 32px */ }
Layout updates globally by remapping one token. No component code changes.
--space-* only inside component internals when necessary. If a spacing pattern repeats across screens, propose a semantic token.
--layout-*. Component internals may use core tokens.Border-radius tokens that define corner rounding across all UI surfaces and controls.
Five radius tokens, from tight controls to fully rounded. Use the smallest radius that matches the component's visual weight. See Rules below for nesting constraints.
| Swatch | Token | Value | Best for | Typical components |
|---|---|---|---|---|
| 6px | --radius-sm |
6px |
Small controls | Checkboxes, radio, toggle track |
| 8px | --radius-md |
8px |
Compact surfaces | Chips, tags, badges, small menus |
| 12px | --radius-lg |
12px |
Default controls + containers | Buttons, inputs, selects, textareas |
| 16px | --radius-xl |
16px |
Large containers | Cards, panels, modals, drawers |
| 9999px | --radius-full |
9999px |
Fully rounded | Pills, avatars, toggle thumb |
:root {
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
}
Quick reference for default radius by component category.
| Component category | Default token | Value |
|---|---|---|
| Checkboxes / Radio | --radius-sm |
6px |
| Toggles (track) | --radius-sm |
6px |
| Chips / Tags / Badges | --radius-md |
8px |
| Buttons / Inputs / Select / Textarea | --radius-lg |
12px |
| Cards / Panels | --radius-xl |
16px |
| Modals / Drawers | --radius-xl |
16px |
| Toggles (thumb) / Pills / Avatars | --radius-full |
9999px |
.control {
min-height: var(--hit-target-min);
padding: 0 var(--space-4);
border-radius: var(--radius-md);
}
.card { border-radius: var(--radius-xl); }
.card__header { border-radius: var(--radius-xl); }
.card__body { border-radius: var(--radius-xl); }
.card__inset {
border-radius: var(--radius-lg); /* <= parent */
}
Elevation tokens that create visual depth to separate layers like cards, menus, and dialogs.
Shadows use two layers: semantic elevation tokens (--elevation-*) define depth roles and map to shadow primitives (--shadow-*).
Use --elevation-* in component CSS so theme tuning is a single remap, not per-component edits.
:root {
/* Shadow primitives */
--shadow-0: none;
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-2: 0 6px 18px rgba(0, 0, 0, 0.35);
--shadow-3: 0 12px 32px rgba(0, 0, 0, 0.40);
/* Elevation tokens (use these in components) */
--elevation-flat: var(--shadow-0);
--elevation-surface: var(--shadow-1);
--elevation-float: var(--shadow-2);
--elevation-overlay: var(--shadow-3);
}
The depth contract. Components reference these tokens; primitives stay internal.
| Token | Maps to | Use |
|---|---|---|
--elevation-flat |
--shadow-0 |
Flat surfaces, intentionally no shadow, or explicitly removing elevation |
--elevation-surface |
--shadow-1 |
Cards, panels, sidebar |
--elevation-float |
--shadow-2 |
Dropdowns, popovers, tooltips, notifications |
--elevation-overlay |
--shadow-3 |
Modals, drawers |
--elevation-flat
--elevation-surface
--elevation-float
--elevation-overlay
Raw values. Use only when defining semantic tokens or one-off exceptions.
| Token | Value |
|---|---|
--shadow-0 |
none |
--shadow-1 |
0 1px 2px rgba(0, 0, 0, 0.35) |
--shadow-2 |
0 6px 18px rgba(0, 0, 0, 0.35) |
--shadow-3 |
0 12px 32px rgba(0, 0, 0, 0.40) |
.card { box-shadow: var(--elevation-surface); }
.popover { box-shadow: var(--elevation-float); }
.modal { box-shadow: var(--elevation-overlay); }
/* For overlay surfaces (modals, drawers), pair elevation with a backdrop */
.modal-backdrop {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.modal-content {
box-shadow: var(--elevation-overlay);
}
Pair --elevation-overlay with a backdrop for full-screen overlay surfaces. This is guidance, not a hard rule.
/* Before */
:root { --shadow-3: 0 12px 32px rgba(0, 0, 0, 0.40); }
/* After */
:root { --shadow-3: 0 16px 48px rgba(0, 0, 0, 0.50); }
Theme tuning happens via token edits, not component edits.
Elevation tokens only
Why: Reference--elevation-* in component CSS. Primitives stay in :root.No custom shadows
Why: Hardcodedbox-shadow values bypass the token layer and break during theme changes.Responsive thresholds that define when layouts and components adapt between device sizes.
Four breakpoint tokens (--bp-*) define every responsive rule in the system.
CSS custom properties cannot be used inside @media conditions. Use the raw value and add a /* --bp-* */ comment so every breakpoint stays traceable to the token set.
The responsive contract. All media queries reference these values.
| Token | Value | Use |
|---|---|---|
--bp-sm |
480px | Narrow viewports under 480px; typically paired with --bp-md for mobile-first ranges |
--bp-md |
768px | Tablet / mobile-to-tablet breakpoint |
--bp-lg |
1024px | Desktop layout breakpoint |
--bp-xl |
1280px | Wide layouts, dashboards |
:root {
--bp-sm: 480px;
--bp-md: 768px;
--bp-lg: 1024px;
--bp-xl: 1280px;
}
Note: CSS custom properties cannot be used inside @media conditions. Use the raw value and add a /* --bp-* */ comment so every breakpoint stays traceable to the token set.
Use min-width queries so styles build up from mobile to desktop. Use max-width only for targeted exceptions or temporary patches.
@media (min-width: 1024px) { /* --bp-lg */
.sidebar { display: block; }
}
.page {
padding: var(--layout-page-padding-y) var(--layout-page-padding-x);
}
@media (max-width: 768px) { /* --bp-md */
.page { padding: var(--layout-page-padding-mobile); }
}
.grid { grid-template-columns: 1fr; }
@media (min-width: 1024px) { /* --bp-lg */
.grid { grid-template-columns: repeat(3, 1fr); }
}
Future: use container queries (@container) for components that resize based on their parent, not the viewport. Media queries remain for page-level changes.
/* Before */
:root { --bp-lg: 1024px; }
/* After */
:root { --bp-lg: 1100px; }
Changing the token updates all responsive rules consistently.
@media (min-width: 1024px) { /* --bp-lg */
.grid { columns: 3; }
}
Tokenized breakpoints
Why: All responsive rules use--bp-* values with inline comments for traceability.@media (min-width: 947px) {
.grid { columns: 3; }
}
Magic breakpoints
Why: One-off breakpoint values fragment responsive behavior and make layout changes harder to audit.Sizing tokens that standardize control heights, icon sizes, and minimum tap targets.
Semantic tokens like --control-height-md point to raw sizes like --size-11. Change the primitive, and all controls update.
Use semantic tokens in component CSS so all controls scale from one place.
The sizing contract. Components reference these tokens; primitives stay internal.
| Token | Value | Use |
|---|---|---|
--control-height-sm |
--size-10 (40px) |
Compact buttons, dense table rows |
--control-height-md |
--size-11 (44px) |
Default buttons, inputs, selects |
--control-height-lg |
--size-12 (48px) |
Hero CTAs, prominent actions |
--icon-size-sm |
--size-4 (16px) |
Inline icons, badges |
--icon-size-md |
--size-5 (20px) |
Default icon size in buttons and nav |
--icon-size-lg |
--size-6 (24px) |
Standalone icons, empty states |
--hit-target-min |
--size-11 (44px) |
WCAG 2.5.8 AAA: 44×44px minimum touch target |
Raw size scale. Use inside component internals or as the base for semantic tokens.
| Token | px |
|---|---|
--size-2 |
8 |
--size-3 |
12 |
--size-4 |
16 |
--size-5 |
20 |
--size-6 |
24 |
| Token | px |
|---|---|
--size-8 |
32 |
--size-10 |
40 |
--size-11 |
44 |
--size-12 |
48 |
The scale is intentionally sparse. Only values used by semantic tokens are defined. Do not add intermediary sizes without updating this contract.
:root {
--control-height-sm: var(--size-10);
--control-height-md: var(--size-11);
--control-height-lg: var(--size-12);
--icon-size-sm: var(--size-4);
--icon-size-md: var(--size-5);
--icon-size-lg: var(--size-6);
--hit-target-min: var(--size-11);
}
.button,
.input { min-height: var(--control-height-md); }
.icon {
width: var(--icon-size-md);
height: var(--icon-size-md);
}
.control {
min-width: var(--hit-target-min);
min-height: var(--hit-target-min);
}
/* Before */
:root { --control-height-md: var(--size-11); /* 44px */ }
/* After */
:root { --control-height-md: var(--size-12); /* 48px */ }
Updates all controls consistently. No component code changes. Note: Only tokens mapped to --size-11 change; unrelated tokens (e.g., --hit-target-min) remain unaffected unless remapped separately.
Use size tokens
Why: Reference--control-height-*, --icon-size-*, and --hit-target-min so all controls scale from one place.Arbitrary heights
Why: Hardcoded pixel sizes bypass the token layer and break during scale changes.A dropdown control that captures one choice from a list of options.
Use a disabled placeholder that describes the decision (Select a category) and treat it as non-selectable.
<div class="flex flex-col gap-2">
<label for="category" class="text-sm font-medium text-white">Category</label>
<div class="relative">
<select
id="category"
class="h-11 w-full px-3 pr-8 rounded-xl bg-zinc-800 border border-zinc-700 text-white text-sm appearance-none cursor-pointer focus:border-cyan-400 focus:ring-2 focus:ring-cyan-400/[.15] outline-none transition-colors">
<option value="" disabled selected>Select a category...</option>
<option value="drums">Drum Kits</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
</div>
<!-- text-sm (14px) = --text-base token. h-11 = --control-height-md (44px), rounded-xl = --radius-lg (12px) -->
<!-- Chevron: pointer-events-none SVG, right-3 = --space-3 (12px) -->
Shows a value has been chosen. Keep option labels short and clear.
<div class="flex flex-col gap-2">
<label for="product-type" class="text-sm font-medium text-white">Product Type</label>
<div class="relative">
<select
id="product-type"
class="h-11 w-full px-3 pr-8 rounded-xl bg-zinc-800 border border-zinc-700 text-white text-sm appearance-none cursor-pointer focus:border-cyan-400 focus:ring-2 focus:ring-cyan-400/[.15] outline-none transition-colors">
<option value="kit">Sample Kit</option>
<option value="plugin">Plugin</option>
<option value="preset">Preset Pack</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
</div>
Disable only when the choice is not applicable. When possible, explain why with helper text.
<div class="flex flex-col gap-2">
<label for="locked" class="text-sm font-medium text-zinc-500">Option</label>
<div class="relative">
<select
id="locked"
class="h-11 w-full px-3 pr-8 rounded-xl bg-zinc-900 border border-zinc-700 text-zinc-500 text-sm appearance-none cursor-not-allowed opacity-60 outline-none"
disabled>
<option>Locked option</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</div>
</div>
Use focus-visible for keyboard users and ensure the selected value is clearly distinguishable from the placeholder. Keyboard: Native <select> keeps built-in keyboard support (Arrow keys, Space/Enter, type-ahead) even when styled with appearance-none. No additional JS is required for accessibility.
A select control uses the native <select> element for accessibility, styled with appearance-none and a custom dropdown indicator. Always pair it with a visible label linked via for/id.
| Property | Token | Value |
|---|---|---|
| Height | --control-height-md | 44px |
| Padding (left) | --space-3 | 12px |
| Padding (right) | --space-8 | 32px (accommodates chevron) |
| Border radius | --radius-lg | 12px |
| Font size | --text-base | 14px |
| Border color | --color-border-subtle | #333333 |
| Focus ring | --color-primary-muted | box-shadow: 0 0 0 2px var(--color-primary-muted) |
5+ options
Why: Select is designed for medium-length option lists. Scannable, unambiguous labels reduce errors.Binary selects
Why: Binary or very small sets are faster as a toggle or radio buttons.Descriptive placeholders
Why: Placeholders that match the label guide the decision without ambiguity.Meaningless placeholders
Why: "---" or bare "Select" tells users nothing about what to choose.A multi-line field that captures longer text such as descriptions, messages, and notes.
Textarea for longer free-form input. Use placeholder as an example, not as instructions.
<div class="flex flex-col gap-2">
<label for="description" class="text-sm font-medium text-white">Description</label>
<textarea
id="description"
class="min-h-[100px] p-3 rounded-xl bg-zinc-800 border border-zinc-700 text-white text-sm placeholder:text-zinc-500 resize-y outline-none focus:border-cyan-400 focus:ring-2 focus:ring-cyan-400/[.15]"
placeholder="Describe your product..."
></textarea>
</div>
Show a live character count when you enforce a max length (maxlength). Count should update as users type.
<div class="flex flex-col gap-2">
<label for="bio" class="text-sm font-medium text-white">Bio</label>
<textarea
id="bio"
class="min-h-[100px] p-3 rounded-xl bg-zinc-800 border border-zinc-700 text-white text-sm placeholder:text-zinc-500 resize-y outline-none focus:border-cyan-400 focus:ring-2 focus:ring-cyan-400/[.15]"
maxlength="200"
aria-describedby="bio-hint bio-count"
>Making beats since 2015. Based in LA.</textarea>
<div class="flex justify-between text-[13px] text-zinc-500">
<span id="bio-hint">Keep it short and memorable</span>
<span id="bio-count" aria-live="polite">37 / 200</span>
</div>
</div>
Use for validation failures and provide a fixable message. Keep error text near the field.
<div class="flex flex-col gap-2">
<label for="description" class="text-sm font-medium text-white">Description</label>
<textarea
id="description"
class="min-h-[100px] p-3 rounded-xl bg-zinc-800 border border-red-500 text-white text-sm placeholder:text-zinc-500 resize-y outline-none focus:border-red-500 focus:ring-2 focus:ring-red-500/[.15]"
aria-invalid="true"
aria-describedby="desc-error"
>x</textarea>
<span id="desc-error" role="alert" class="text-[13px] text-red-500">Description must be at least 20 characters</span>
</div>
Keep focus-visible behavior consistent with Input. Error styling should remain readable without removing the focus ring.
A complete textarea includes a visible label, the multi-line input area, and optional helper or error text linked via aria-describedby. If a character count is shown, expose it via aria-live so changes are announced.
| Property | Token | Value |
|---|---|---|
| Min height | Tailwind: min-h-[100px] |
100px |
| Padding | --space-3 |
12px |
| Border radius | --radius-lg |
12px |
| Font size | --text-base |
14px |
| Line height | --leading-normal |
1.5 |
| Background | --color-bg-surface |
#1A1A1A |
| Border color | --color-border-subtle |
#333333 |
| Text color | --color-text |
#F5F5F5 |
| Placeholder color | --color-text-dim |
#888888 |
| Border (focus) | --color-primary |
#6BDDFF |
| Focus ring | --color-primary-muted |
box-shadow: 0 0 0 2px var(--color-primary-muted) |
| Border (error) | --color-error |
#EF4444 |
| Label font size | --text-base |
14px |
| Helper / error text size | --text-sm |
13px |
| Resize | - | vertical only |
| Elevation | --elevation-flat |
none |
Show count when enforced
Why: Show character count only when a limit exists and the system enforces it.Lock height
Why: Do not lock textarea height for long-form content. Allow vertical resize or autosize.Use a visible label
Why: Always render a visible label and link it viafor/id.Placeholder as label
Why: Do not rely on placeholder text as the label. Placeholders disappear on input and are not announced reliably by all screen readers.A trigger control that opens a command palette or search surface for fast navigation and actions.
Primary trigger with icon, hint text, and shortcut. Opens the command palette on click and on ⌘K or Ctrl K.
<button
type="button"
class="flex items-center gap-3 px-3 py-2 min-w-[240px] bg-zinc-800 border border-zinc-700 rounded-xl hover:border-zinc-600 transition-all"
aria-haspopup="dialog"
aria-label="Search products, ⌘K"
>
<svg class="w-4 h-4 text-zinc-500">...</svg>
<span class="flex-1 text-sm text-zinc-500 text-left">Search products...</span>
<kbd class="px-1.5 py-0.5 text-xs font-mono text-zinc-500 bg-zinc-900 rounded">⌘K</kbd>
</button>
Space-efficient trigger for nav bars. Includes aria-label and a title tooltip since the hint text is omitted.
<button
type="button"
class="flex items-center gap-3 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-xl hover:border-zinc-600 transition-all"
aria-haspopup="dialog"
aria-label="Search, ⌘K"
title="Search (⌘K)"
>
<svg class="w-4 h-4 text-zinc-500">...</svg>
<kbd class="px-1.5 py-0.5 text-xs font-mono text-zinc-500 bg-zinc-900 rounded">⌘K</kbd>
</button>
Focus-visible ring is required. Hover should not change layout or shift content. States shown for Default variant. Compact follows the same hover/focus states with reduced layout.
The search command trigger includes a search icon, a hint describing what can be searched, and a keyboard shortcut badge. The Compact variant omits the hint text, so an aria-label is required.
| Property | Token | Value |
|---|---|---|
| Min width (Default) | - | 240px |
| Padding | --space-2, --space-3 |
8px 12px |
| Border radius | --radius-lg |
12px |
| Icon size | --icon-size-sm |
16px |
| Gap | --space-3 |
12px |
| Background | --color-bg-surface |
#1A1A1A |
| Border color | --color-border-subtle |
#333333 |
| Border (hover) | --color-border |
#444444 |
| Text / placeholder color | --color-text-muted |
#71717A |
| Focus ring | --color-primary-muted |
box-shadow: 0 0 0 2px var(--color-primary-muted) |
| Shortcut badge background | --color-bg-elevated |
#111111 |
| Shortcut font family | --font-mono |
monospace |
| Shortcut font size | --text-xs |
12px |
| Elevation | --elevation-flat |
none |
Bind ⌘K (macOS) / Ctrl K (Windows/Linux) to open the command palette. Keep the listener on document so it works regardless of focus.
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
openCommandPalette();
}
});
Show the shortcut
Why: Always display the shortcut and render the correct modifier for the platform (⌘ on macOS, Ctrl on Windows).Use a text input
Why: Do not use a real text input unless the user can type inline. This component should open a modal.Label compact triggers
Why: Providearia-label for Compact triggers; keep ⌘K / Ctrl K discoverable via <kbd>.Conflict with OS shortcuts
Why: Don't bind shortcuts that conflict with browser/OS defaults; keep shortcuts scoped to the app shell.An on/off switch that takes effect immediately when toggled.
A compact switch for immediate on and off behavior. The two previews show the same element in its Off and On states.
<!-- Same element: toggle aria-checked to switch state -->
<button
type="button"
role="switch"
aria-checked="false"
aria-label="Toggle setting"
class="relative w-11 h-6 bg-zinc-800 border border-zinc-700 rounded-full cursor-pointer transition-all focus-visible:ring-2 focus-visible:ring-cyan-400/40 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900"
>
<span class="absolute top-px left-px w-5 h-5 bg-zinc-500 rounded-full transition-all"></span>
</button>
<!-- On state: swap classes + aria-checked -->
<button
type="button"
role="switch"
aria-checked="true"
aria-label="Toggle setting"
class="relative w-11 h-6 bg-cyan-400 border border-cyan-400 rounded-full cursor-pointer transition-all focus-visible:ring-2 focus-visible:ring-cyan-400/40 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900"
>
<span class="absolute top-px left-[21px] w-5 h-5 bg-zinc-900 rounded-full transition-all"></span>
</button>
<script>
// Toggle state on click
toggle.addEventListener('click', () => {
const on = toggle.getAttribute('aria-checked') !== 'true';
toggle.setAttribute('aria-checked', String(on));
});
</script>
Switch with label and helper text for settings pages. Keep the label as the state change (Enable notifications) and keep helper text short.
<div class="flex items-center gap-3">
<button
type="button"
role="switch"
aria-checked="true"
aria-labelledby="notif-label"
aria-describedby="notif-desc"
class="relative w-11 h-6 bg-cyan-400 border border-cyan-400 rounded-full cursor-pointer transition-all focus-visible:ring-2 focus-visible:ring-cyan-400/40 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900"
>
<span class="absolute top-px left-[21px] w-5 h-5 bg-zinc-900 rounded-full transition-all"></span>
</button>
<div>
<span id="notif-label" class="text-sm text-white">Email notifications</span>
<span id="notif-desc" class="block text-sm text-zinc-500">Receive updates about your sales</span>
</div>
</div>
Show clear off and on states. Focus-visible must be obvious for keyboard users. Disabled states should be used sparingly and explained when possible.
A toggle includes a track, a thumb, and an optional label and helper text. Implement as <button role="switch" aria-checked="true|false">. Keyboard: activate with Space or Enter. On toggle, update aria-checked immediately and sync the visual state.
Use tokens for size, radius, and color. Track and thumb sizing should remain consistent across the app to avoid layout drift.
| Property | Token | Value |
|---|---|---|
| Track width | --toggle-width |
44px |
| Track height | --toggle-height |
24px |
| Thumb size | --toggle-thumb |
20px |
| Border radius | --radius-full |
9999px |
| Track bg (off) | --color-bg-surface |
#1A1A1A |
| Track border (off) | --color-border-subtle |
#333333 |
| Thumb bg (off) | --color-text-muted |
#71717A |
| Track bg (on) | --color-primary |
#6BDDFF |
| Track border (on) | --color-primary |
#6BDDFF |
| Thumb bg (on) | --color-bg-page |
#0A0A0A |
| Disabled opacity | - | 0.5 |
| Focus ring | --color-primary |
box-shadow: 0 0 0 2px var(--color-bg-page), 0 0 0 4px var(--color-primary) |
| Transition | - | 200ms ease |
Always use <button role="switch" aria-checked="true|false"> for instant-effect toggles. Use a checkbox when the change is submitted as part of a form.
Choose a toggle when the user expects instant effect. If the change requires review, confirmation, or batch saving, use a checkbox or a dedicated control.
Immediate settings
Why: Use toggles for settings that apply instantly and can be reversed without confirmation.Consent or agreement
Why: Do not use toggles for consent or legal agreement. Use a checkbox and require an explicit submit action.A row of options where selecting one deselects the others.
Text-only segmented control for simple binary or ternary choices.
<div class="inline-flex p-0.5 bg-zinc-800 border border-zinc-700 rounded-xl">
<button class="px-4 py-2 text-sm font-medium text-zinc-900 bg-cyan-400 rounded-lg">
Creator
</button>
<button class="px-4 py-2 text-sm font-medium text-zinc-400 hover:text-white rounded-lg">
Admin
</button>
</div>
Icon-only variant for compact spaces like toolbars.
<div class="inline-flex p-0.5 bg-zinc-800 border border-zinc-700 rounded-xl">
<button class="p-2 text-zinc-900 bg-cyan-400 rounded-lg">
<svg class="w-4 h-4">...</svg>
</button>
<button class="p-2 text-zinc-400 hover:text-white rounded-lg">
<svg class="w-4 h-4">...</svg>
</button>
</div>
| Property | Token | Value |
|---|---|---|
| Container padding | -- | 2px |
| Segment padding | --space-2, --space-4 | 8px 16px |
| Border radius (outer) | --radius-lg | 12px |
| Border radius (inner) | --radius-md | 8px |
| Font size | --text-sm | 13px |
| Active color | --color-primary | #6BDDFF |
A control that lets users select zero, one, or many independent options from a set.
Unchecked and checked states for basic multi-select.
<!-- Unchecked -->
<button class="w-5 h-5 rounded bg-zinc-800 border border-zinc-700 cursor-pointer transition-all hover:border-zinc-600"></button>
<!-- Checked -->
<button class="w-5 h-5 rounded bg-cyan-400 border-cyan-400 flex items-center justify-center">
<svg class="w-3 h-3 text-zinc-900" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12"/>
</svg>
</button>
Checkbox paired with a clickable label and optional helper text. Link the label to the input so clicking the text also toggles the checkbox.
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" class="sr-only peer" />
<div class="w-5 h-5 rounded bg-zinc-800 border border-zinc-700 peer-checked:bg-cyan-400 peer-checked:border-cyan-400"></div>
<div>
<span class="text-sm text-white">Receive email updates</span>
<span class="block text-sm text-zinc-500">We'll send you news...</span>
</div>
</label>
The mixed state (dash) appears when a "Select all" checkbox has only some children checked.
<!-- Indeterminate (via JS: checkbox.indeterminate = true) -->
<div class="w-5 h-5 rounded bg-cyan-400 border-cyan-400 flex items-center justify-center">
<div class="w-2.5 h-0.5 bg-zinc-900"></div>
</div>
Provide clear checked, unchecked, indeterminate, focus-visible, and disabled states. Disabled fields should be paired with a reason when they block progress.
A checkbox includes the control, a label, and optional helper or error text. Ensure the label is clickable and that the hit target is large enough for touch.
Use tokens for size, border, radius, and checkmark color. Keep the control size consistent across forms and tables.
| Property | Token | Value |
|---|---|---|
| Size | --checkbox-size | 20px × 20px |
| Border radius | --radius-sm | 6px |
| Border color | --color-border-subtle | #333333 |
| Checked background | --color-primary | #6BDDFF |
| Check icon | -- | 2px stroke, dark |
| Label gap | --space-3 | 12px |
Choose checkboxes for independent selection. Prefer full-row click targets in lists, and keep labels concise and affirmative.
Consent and confirmation
Why: Checkboxes defer action until form submission, making them ideal for consent and agreements.Immediate setting
Why: Checkboxes are for deferred actions. A toggle signals instant effect.Multi-select lists
Why: Checkboxes allow independent, non-exclusive choices. The indeterminate state communicates partial selection.Mutually exclusive choice
Why: Radios enforce one-of-many selection. Checkboxes imply multiple choices are valid.Choose exactly one option from a visible list of 2–5 choices.
Basic radios in unselected and selected states. Use a group label when the meaning is not obvious.
Use a clear label per option. Make the whole row clickable for easier selection.
Use descriptions when options need tradeoffs. Keep descriptions short and parallel in structure.
Disable options only when they are not available. Prefer explaining why with helper text.
Support unselected, selected, focus-visible, and disabled states. Arrow keys should move between options, and selection should be announced by assistive tech.
A radio includes an outer control, an inner indicator for selected state, and a label. Group radios with a legend when they answer a single question.
Use tokens for size and colors. Keep the control size aligned with Checkbox so form rows stay consistent.
| Property | Value | Notes |
|---|---|---|
| Height | 20px | Matches checkbox control size |
| Border | 1px | Use --color-border-subtle |
| Indicator | 10px | Inner dot for selected state |
| Hit target | min 40px | Use full-row click target in lists |
| Focus ring | 2px | Use :focus-visible and --color-primary |
Use radios when only one option can be selected. Keep option count low and make the tradeoffs clear.
Mutually exclusive
Why: Radios make all options visible and enforce single selection without extra clicks.Long lists
Why: Radios become unwieldy beyond 5-7 items. Select keeps the UI compact.Switch between related content panels without leaving the page.
Contained tabs for compact surfaces like toolbars, cards, and dialogs. Use when the tab set is local to a component.
<!-- Tablist -->
<div role="tablist" aria-label="Section tabs" class="inline-flex gap-1 p-1 bg-zinc-900 rounded-xl">
<button
type="button" role="tab" id="tab-overview"
aria-selected="true" tabindex="0"
aria-controls="panel-overview"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-zinc-950 bg-cyan-300 rounded-lg"
>
<!-- Icon: 16×16, stroke-width="2", currentColor -->
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
Overview
</button>
<button
type="button" role="tab" id="tab-files"
aria-selected="false" tabindex="-1"
aria-controls="panel-files"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-zinc-400 hover:text-white rounded-lg"
>
<svg class="w-4 h-4">...</svg>
Files
</button>
<button
type="button" role="tab" id="tab-settings"
aria-selected="false" tabindex="-1"
aria-controls="panel-settings"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-zinc-400 hover:text-white rounded-lg"
>
<svg class="w-4 h-4">...</svg>
Settings
</button>
</div>
<!-- Tabpanels -->
<div role="tabpanel" id="panel-overview" aria-labelledby="tab-overview">
<!-- Overview content -->
</div>
<div role="tabpanel" id="panel-files" aria-labelledby="tab-files" hidden>
<!-- Files content -->
</div>
<div role="tabpanel" id="panel-settings" aria-labelledby="tab-settings" hidden>
<!-- Settings content -->
</div>
Underline tabs for page-level sections like settings or detail views. Use when tabs represent stable peer sections of the same page.
<!-- Tablist -->
<div role="tablist" aria-label="Account tabs" class="flex gap-6 border-b border-zinc-800">
<button
type="button" role="tab" id="tab-profile"
aria-selected="true" tabindex="0"
aria-controls="panel-profile"
class="py-3 text-sm font-medium text-cyan-400 border-b-2 border-cyan-400 -mb-px"
>Profile</button>
<button
type="button" role="tab" id="tab-storefront"
aria-selected="false" tabindex="-1"
aria-controls="panel-storefront"
class="py-3 text-sm font-medium text-zinc-400 hover:text-white border-b-2 border-transparent -mb-px"
>Storefront</button>
<button
type="button" role="tab" id="tab-payments"
aria-selected="false" tabindex="-1"
aria-controls="panel-payments"
class="py-3 text-sm font-medium text-zinc-400 hover:text-white border-b-2 border-transparent -mb-px"
>Payments</button>
<button
type="button" role="tab" id="tab-notifications"
aria-selected="false" tabindex="-1"
aria-controls="panel-notifications"
class="py-3 text-sm font-medium text-zinc-400 hover:text-white border-b-2 border-transparent -mb-px"
>Notifications</button>
</div>
<!-- Tabpanels -->
<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">
<!-- Profile content -->
</div>
<div role="tabpanel" id="panel-storefront" aria-labelledby="tab-storefront" hidden>
<!-- Storefront content -->
</div>
<div role="tabpanel" id="panel-payments" aria-labelledby="tab-payments" hidden>
<!-- Payments content -->
</div>
<div role="tabpanel" id="panel-notifications" aria-labelledby="tab-notifications" hidden>
<!-- Notifications content -->
</div>
Active, hover, and focus-visible states must be distinct. The active tab should be visually anchored and remain readable on dark surfaces.
A tab set includes a tablist, tab buttons, and tabpanel elements. Preserve keyboard behavior: Tab focuses the active tab, arrow keys move between tabs. Diagram shows Contained variant; Underline uses the same semantics with a different indicator.
Use tokens for typography, spacing, and indicator color. Keep the active indicator consistent across variants to reinforce hierarchy.
| Property | Contained | Underline |
|---|---|---|
| Container padding | --tab-container-padding (4px) |
n/a |
| Tab padding (vertical) | --tab-padding-y-contained (8px) |
--tab-padding-y-underline (12px) |
| Tab padding (horizontal) | --tab-padding-x-contained (16px) |
--tab-padding-x-underline (0) |
| Gap between tabs | --tab-gap-contained (4px) |
--tab-gap-underline (24px) |
| Font size | --tab-font-size (14px via --text-base) |
|
| Container radius | --tab-radius-contained (12px) |
n/a |
| Active indicator | --color-primary (background fill) |
--tab-indicator-height (2px bottom border) |
| Active text color | --color-bg-page (#0A0A0A) |
--color-primary (#6BDDFF) |
| Inactive text color | --color-text-muted (#71717A) |
|
| Focus ring | box-shadow: 0 0 0 2px var(--color-bg-page), 0 0 0 4px var(--color-primary) | |
Tabs are a navigation control, not a progress control. Favor clarity over density and keep the tab count low.
Peer sections
Why: Use tabs to group peer content where switching is non-destructive and users do not need to see multiple sections at once.Sequential flow
Why: Do not use tabs for step-by-step tasks. Use a stepper or wizard for sequential workflows.A compact, non-interactive label that communicates status, category, or count at a glance.
Use Status badges to communicate meaning (Success/Warning/Error). Use Decorative badges for categorization or branding where color is not the only signal.
<!-- Status -->
<span class="badge badge-success">Success</span>
<span class="badge badge-warning">Warning</span>
<span class="badge badge-error">Error</span>
<!-- Decorative / Brand -->
<span class="badge badge-primary">Primary</span>
<span class="badge badge-solid">Solid</span>
<span class="badge badge-purple">Purple</span>
<span class="badge badge-neutral">Neutral</span>
In tables, ensure status meaning is also represented in text or column headers. Badges are typically decorative; avoid relying on color alone.
Use badges to make lists scannable: status, genre, plan tier, or counts. Keep labels short and do not overload with multiple meanings.
Badges should be stable in height and padding so they align cleanly in tables and dense layouts. Use tokens only.
| Property | Token | Value |
|---|---|---|
| Padding (vertical) | --badge-padding-y |
2px |
| Padding (horizontal) | --badge-padding-x |
8px |
| Font size | --text-xs |
12px |
| Font weight | --font-medium |
500 |
| Border radius | --radius-full |
9999px |
| Background opacity | --badge-bg-opacity |
0.15 (Neutral: 0.10) |
Badges communicate meaning at a glance. Keep them consistent across the product and treat status colors as reserved.
Meaningful status
Why: Match color to meaning and keep the label explicit (Active, Pending, Failed).Decorative color
Why: Do not choose a badge color for aesthetics if it conflicts with meaning or reduces contrast.Keep labels short
Why: Keep labels to 1–2 words so badges remain scannable in dense layouts.Long badge text
Why: Long descriptive text breaks layout alignment. Use a tooltip or inline text instead.A visual identifier that represents a person or entity to provide ownership and context.
Four sizes cover most use cases, from inline mentions to profile headers.
<!-- Small: 24 px -->
<div class="avatar avatar-sm" role="img" aria-label="Jane Doe">JD</div>
<!-- Medium: 32 px -->
<div class="avatar avatar-md" role="img" aria-label="Jane Doe">JD</div>
<!-- Large: 40 px -->
<div class="avatar avatar-lg" role="img" aria-label="Jane Doe">JD</div>
<!-- XL: 56 px -->
<div class="avatar avatar-xl" role="img" aria-label="Jane Doe">JD</div>
<!-- Note: Tailwind text-sm = 14 px, but the design-system token --text-sm = 13 px.
The .avatar-* classes use the token values. -->
Image, initials fallback, and status indicators cover all common avatar states.
<!-- With image -->
<div class="avatar avatar-lg">
<img src="avatar.jpg" alt="Jane Doe" />
</div>
<!-- Initials fallback -->
<div class="avatar avatar-lg" role="img" aria-label="Jane Doe">JD</div>
<!-- With status: Online -->
<div class="avatar avatar-lg" role="img" aria-label="Jane Doe">
JD
<span class="avatar-status online" role="status" aria-label="Online"></span>
</div>
<!-- With status: Busy -->
<div class="avatar avatar-lg" role="img" aria-label="Jane Doe">
JD
<span class="avatar-status busy" role="status" aria-label="Busy"></span>
</div>
All dimensions reference component tokens so sizes stay consistent across themes.
| Size | Token | Dimensions | Font Size Token | Font Size | Status Token | Status Size |
|---|---|---|---|---|---|---|
| Small | --avatar-sm |
24px | --avatar-font-sm |
10px | --avatar-status-sm |
6px |
| Medium | --avatar-md |
32px | --avatar-font-md |
12px | --avatar-status-md |
8px |
| Large | --avatar-lg |
40px | --avatar-font-lg |
14px | --avatar-status-lg |
10px |
| XL | --avatar-xl |
56px | --avatar-font-xl |
18px | --avatar-status-xl |
12px |
Patterns for initials, status indicators, and accessible labelling.
Two-letter initials
Use first + last initials as fallback when no image is available. Why: Two letters are easy to scan and keep the circle balanced visually.Full names in the circle
Don't use full names or more than 2 characters in the fallback. Why: Long strings shrink the font, making the avatar unreadable at small sizes.Label initials avatars for assistive tech
Addrole="img" and aria-label with the person's full name on initials avatars. Why: Screen readers can't infer identity from two letters alone.Status color as the only signal
Don't rely solely on the status dot color to convey state. Why: Color alone fails WCAG. Always pair withrole="status" and aria-label on the dot.An avatar is a circle containing either an <img> or initials text, optionally overlaid with a status dot.
border-radius: var(--radius-full))
<img> with descriptive alt, or initials with role="img" + aria-label
role="status" + aria-label
A bordered container that groups related content and actions into one unit.
Feature cards highlight capabilities with an icon, title, and description. Use on marketing pages.
<div class="p-6 bg-zinc-900 border border-[var(--card-border-color)] rounded-2xl flex flex-col gap-4">
<!-- Icon: 24×24; use currentColor. Wrapper w-10 h-10 sets visual size; icon inherits text-cyan-400 -->
<div class="w-10 h-10 flex items-center justify-center bg-cyan-400/15 rounded-xl text-cyan-400">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
</div>
<h3 class="text-base font-semibold text-white">Instant Delivery</h3>
<p class="text-sm text-zinc-400 leading-relaxed">Files are delivered immediately after purchase. No waiting, no friction.</p>
</div>
Stat cards display key metrics with optional trend indicators. Use in dashboards.
<!-- Positive trend -->
<!-- Provide context since there is no heading in this card. -->
<article aria-label="Revenue summary" class="p-5 bg-zinc-900 border border-[var(--card-border-color)] rounded-2xl flex flex-col gap-2">
<span class="text-sm text-zinc-400">Total Revenue</span>
<span class="text-[var(--card-stat-value)] font-semibold text-white">$12,450</span>
<span class="flex items-center gap-1 text-sm text-green-400">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 15l-6-6-6 6"/></svg>
+12.5%
</span>
</article>
<!-- Negative trend -->
<article aria-label="Downloads summary" class="p-5 bg-zinc-900 border border-[var(--card-border-color)] rounded-2xl flex flex-col gap-2">
<span class="text-sm text-zinc-400">Downloads</span>
<span class="text-[var(--card-stat-value)] font-semibold text-white">3,847</span>
<span class="flex items-center gap-1 text-sm text-red-400">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
-2.3%
</span>
</article>
Product cards show items for sale with image, details, and price. Use in storefronts.
<div class="bg-zinc-900 border border-[var(--card-border-color)] rounded-2xl overflow-hidden">
<div class="h-36 bg-zinc-800 flex items-center justify-center">
<img src="product.jpg" alt="Midnight Drum Kit cover art" class="w-full h-full object-cover" />
</div>
<div class="p-4 flex flex-col gap-2">
<h3 class="text-[var(--card-product-title)] font-medium text-white">Midnight Drum Kit</h3>
<p class="text-sm text-zinc-400">45 samples • WAV</p>
<span class="text-base font-semibold text-cyan-400">$29.00</span>
</div>
</div>
Use card tokens for padding, border, and typography so variants stay consistent and themeable without per-component overrides.
| Property | Token | Value |
|---|---|---|
| Padding (Feature) | --card-padding-feature |
24px |
| Padding (Stat) | --card-padding-stat |
20px |
| Padding (Product) | --card-padding-product |
16px |
| Border radius | --card-radius |
16px (via --radius-xl) |
| Border color | --card-border-color |
#2A2A2A |
| Stat value size | --card-stat-value |
28px semibold |
| Product title size | --card-product-title |
15px medium |
| Feature title size | - | 16px semibold |
| Elevation | --elevation-surface |
Foundation token |
Consistent card groups
Keep cards in a group visually consistent with same height and structure. Why: Uniform cards create a scannable grid and reduce cognitive load.This card has too much text that makes it hard to scan. Cards should be concise and focused on a single piece of information or action.
Text-heavy cards
Don't overload cards with too much content. Keep them scannable. Why: Dense text defeats the purpose of a card. Users should grasp the content at a glance.A visual indicator that communicates completion state for tasks and processes. All bars include animated diagonal stripes to signal active progress.
Simple progress bar showing completion percentage with animated diagonal stripes. The preview shows three fill levels; the code demonstrates one.
<!-- Track: var(--color-bg-surface) = #1A1A1A -->
<div
class="w-full h-2 bg-[var(--color-bg-surface)] rounded-full overflow-hidden"
role="progressbar"
aria-valuenow="50"
aria-valuemin="0"
aria-valuemax="100"
aria-label="Upload progress"
>
<div class="h-full bg-cyan-400 rounded-full transition-all" style="width: 50%"></div>
</div>
Progress bar with label and percentage for detailed feedback.
<div class="flex flex-col gap-2">
<div class="flex justify-between text-[13px]">
<!-- text-[13px] = system --text-sm (Tailwind text-sm = 14px) -->
<span id="upload-label" class="text-white">Uploading samples...</span>
<span class="text-zinc-400">67%</span>
</div>
<div
class="w-full h-2 bg-[var(--color-bg-surface)] rounded-full overflow-hidden"
role="progressbar"
aria-valuenow="67"
aria-valuemin="0"
aria-valuemax="100"
aria-labelledby="upload-label"
>
<div class="h-full bg-cyan-400 rounded-full transition-all" style="width: 67%"></div>
</div>
</div>
Color signals status: green for healthy, amber for warning, red for critical. Stripes animate across all color variants.
<!-- Success (healthy): full example -->
<div class="flex flex-col gap-2">
<div class="flex justify-between text-[13px]">
<span id="storage-label" class="text-white">Storage: healthy</span>
<span class="text-green-400">45%</span>
</div>
<div
class="w-full h-2 bg-[var(--color-bg-surface)] rounded-full overflow-hidden"
role="progressbar"
aria-valuenow="45"
aria-valuemin="0"
aria-valuemax="100"
aria-labelledby="storage-label"
>
<div class="h-full bg-green-500 rounded-full transition-all" style="width: 45%"></div>
</div>
</div>
<!-- Warning: change fill color only -->
<div class="h-full bg-amber-500 rounded-full transition-all" style="width: 78%"></div>
<!-- Error: change fill color only -->
<div class="h-full bg-red-500 rounded-full transition-all" style="width: 95%"></div>
Use tokens for track height, radius, and background. Keep the bar height consistent to maintain alignment in stacked layouts.
| Property | Token | Value |
|---|---|---|
| Track height | --progress-height |
8px |
| Border radius | --radius-full |
9999px |
| Track background | --color-bg-surface |
#1A1A1A |
| Label font size | --text-sm |
13px |
| Transition | --progress-transition |
300ms ease |
Use progress bars for operations with known completion. Choose colors to communicate urgency.
Count + percentage
Show both count and percentage for multi-item operations. Why: Users need context. "3 of 5" is more useful than "60%" alone.Unknown duration
Don't use determinate progress for unknown durations. Use a spinner instead. Why: A full bar that never finishes destroys user trust.Label every progress bar
Always pair the bar with a visible text label and ARIA attributes (role="progressbar", aria-valuenow, aria-label or aria-labelledby). Why: Screen readers and sighted users both need to know what the bar represents.Color-only progress without text
Don't show a colored bar with no label or percentage. Why: Without text context, users can't tell what the bar measures or how urgent the state is.A structured grid that presents data for scanning, comparison, and sorting across rows and columns.
A product data table with status badges, numeric columns, and hover rows. Use this structure as the starting point for all data tables.
<!-- Border: var(--color-border) = #2A2A2A -->
<!-- Hover: var(--color-hover-surface) = rgba(255,255,255,0.02) -->
<div class="bg-zinc-900 border border-[#2A2A2A] rounded-2xl overflow-hidden">
<table class="w-full">
<caption class="sr-only">Product sales overview</caption>
<thead>
<tr>
<!-- Sort: toggle aria-sort and update indicator; keep button focusable -->
<th scope="col" aria-sort="ascending" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-zinc-500 bg-zinc-900 border-b border-[#2A2A2A]">
<button type="button" class="inline-flex items-center gap-1">
Product <span aria-hidden="true">↓</span>
</button>
</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wide text-zinc-500 bg-zinc-900 border-b border-[#2A2A2A]">Sales</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wide text-zinc-500 bg-zinc-900 border-b border-[#2A2A2A]">Revenue</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wide text-zinc-500 bg-zinc-900 border-b border-[#2A2A2A]">Status</th>
</tr>
</thead>
<tbody>
<tr class="hover:bg-white/[0.02]">
<td class="px-4 py-3 text-sm text-white border-b border-[#2A2A2A]">Midnight Drum Kit</td>
<td class="px-4 py-3 text-sm text-white text-right border-b border-[#2A2A2A]">234</td>
<td class="px-4 py-3 text-sm text-white text-right border-b border-[#2A2A2A]">$6,786</td>
<td class="px-4 py-3 border-b border-[#2A2A2A]">
<span class="badge badge-success">Active</span>
</td>
</tr>
<!-- Additional rows follow the same pattern -->
...
</tbody>
</table>
</div>
A table includes a header row with column labels, data rows with cell content, row dividers, and an optional sort control.
Use spacing and typography tokens for consistent alignment. Keep cell padding uniform across all tables.
| Property | Token | Value |
|---|---|---|
| Header padding | --space-3, --space-4 |
12px 16px |
| Cell padding | --space-3, --space-4 |
12px 16px |
| Header font | --text-xs |
12px uppercase |
| Cell font | - | 14px (Tailwind text-sm; system --text-sm is 13px) |
| Border | --color-border |
1px #2A2A2A |
| Hover background | --color-hover-surface |
rgba(255,255,255,0.02) |
Sortable columns
Allow sorting on columns where it helps users find data. Why: Sorting reduces scan time and lets users answer their own questions without filtering.Column overload
Don't show too many columns. Prioritize the most important data. Why: Wide tables force horizontal scrolling and break scanning flow.Horizontal scroll on mobile
Wrap tables in anoverflow-x container for narrow viewports; preserve column structure. Why: Horizontal scroll keeps column relationships intact and is a familiar mobile pattern.Stacking rows into cards
Avoid converting tables to stacked cards unless the layout is designed for it. Why: Stacking breaks scanning and makes comparisons across rows impossible.A titled header block that introduces and separates a content group within a page. It can stick to the top of its scroll container and can include optional tab navigation.
Sticky section header with icon and title. It stays visible while the section content scrolls.
<!-- Sticky section header -->
<div style="position: sticky; top: 0; z-index: 10; background: rgba(26,26,26,0.9); backdrop-filter: blur(8px); border-bottom: 1px solid var(--color-border); padding: 12px 20px;">
<div style="display: flex; align-items: center; gap: var(--space-3);">
<div style="width: 36px; height: 36px; border-radius: var(--radius-lg); background: rgba(107,221,255,0.15); display: flex; align-items: center; justify-content: center; color: var(--color-primary);">
<svg width="18" height="18">...</svg>
</div>
<h3 style="font-size: 16px; font-weight: 600; color: var(--color-text);">General Settings</h3>
</div>
</div>
Sticky section header with optional tab navigation. Tabs remain accessible while the section content scrolls.
<!-- Sticky section header with tabs -->
<div style="position: sticky; top: 0; z-index: 10; background: rgba(26,26,26,0.9); backdrop-filter: blur(8px); border-bottom: 1px solid var(--color-border);">
<div style="padding: 12px 20px 0;">
<div style="display: flex; align-items: center; gap: var(--space-3);">
<div style="width: 36px; height: 36px; border-radius: var(--radius-lg); background: rgba(107,221,255,0.15); display: flex; align-items: center; justify-content: center; color: var(--color-primary);">
<svg width="18" height="18">...</svg>
</div>
<h3 style="font-size: 16px; font-weight: 600; color: var(--color-text);">Settings</h3>
</div>
</div>
<!-- Tab navigation -->
<nav style="padding: 0 20px; margin-top: 12px;" aria-label="Section tabs">
<div style="display: flex; gap: 24px; border-bottom: 1px solid var(--color-border);">
<button style="padding-bottom: 12px; font-size: 14px; font-weight: 500; color: var(--color-primary); border-bottom: 2px solid var(--color-primary); margin-bottom: -1px;">Profile</button>
<button style="padding-bottom: 12px; font-size: 14px; font-weight: 500; color: var(--color-text-muted);">Storefront</button>
</div>
</nav>
</div>
The section header has a sticky wrapper, a header row for icon and title, and an optional tab row for in-section navigation.
Sizing, spacing, and typography values for the section header component.
| Property | Token | Value |
|---|---|---|
| Icon container | - | 36 × 36px |
| Icon size | - | 18px |
| Icon background | --color-primary-muted |
15 % primary |
| Icon border-radius | --radius-md |
8px |
| Title font | - | Tailwind text-base (16px), 600 |
| Description font | - | Tailwind text-sm (14px); system --text-sm is 13px |
| Gap | --space-3 |
12px |
Use sticky section headers when the section is long enough that the title or tabs would scroll out of view.
Clear section titles
Why: Descriptive titles let users scan the page and jump to the section they need without reading every field.Generic labels
Why: Numbered or generic labels force users to read the content to understand what a section contains, slowing down navigation.Meaningful icons
Why: An icon that reinforces the title provides a secondary visual cue, helping users locate sections faster when scanning.Decorative-only icons
Why: An icon unrelated to the section content creates visual noise and can mislead users about what the section contains.A file selection surface that supports browse and drag-and-drop for uploading images, audio, and documents.
An image upload zone that shows accepted formats (JPG, PNG, WebP) and max file size.
<!-- Image upload dropzone -->
<label aria-label="Upload image"
class="media-upload"
style="width: 360px;">
<input type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
class="sr-only" />
<svg class="media-upload-icon" width="32" height="32"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<p class="media-upload-text">
<strong>Click to upload</strong> or drag and drop
</p>
<p class="media-upload-hint">PNG, JPG, GIF, WebP up to 10 MB. 1:1 or 16:9.</p>
</label>
Dropzone for audio files. Accepts common lossless and compressed formats with duration limits.
<!-- Audio upload dropzone -->
<label aria-label="Upload audio"
class="media-upload"
style="width: 360px;">
<input type="file"
accept="audio/mpeg,audio/wav,audio/flac,audio/aac"
class="sr-only" />
<!-- Music note icon -->
<svg class="media-upload-icon" width="32" height="32"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 18V5l12-2v13"/>
<circle cx="6" cy="18" r="3"/>
<circle cx="18" cy="16" r="3"/>
</svg>
<p class="media-upload-text">
<strong>Click to upload</strong> or drag and drop
</p>
<p class="media-upload-hint">MP3, WAV, FLAC, AAC up to 50 MB. Max 10 min.</p>
</label>
Dropzone for documents and archives. Accepts PDF, Word, and compressed files.
<!-- Document upload dropzone -->
<label aria-label="Upload document"
class="media-upload"
style="width: 360px;">
<input type="file"
accept="application/pdf,.doc,.docx,.zip"
class="sr-only" />
<!-- File icon -->
<svg class="media-upload-icon" width="32" height="32"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<p class="media-upload-text">
<strong>Click to upload</strong> or drag and drop
</p>
<p class="media-upload-hint">PDF, DOC, ZIP up to 25 MB.</p>
</label>
Visual states for hover, drag-over, upload progress, success, error, and disabled.
Preview layout after a successful upload, including remove action and optional "Add more" slot.
<!-- Image preview -->
<div style="display: flex; gap: 12px; align-items: flex-start;">
<div style="position: relative;">
<img src="cover-art.png" alt="Uploaded cover art thumbnail"
class="mu-preview-thumb" />
<button type="button" aria-label="Remove file" title="Remove file"
class="mu-remove-btn"
style="position: absolute; top: -8px; right: -8px;">
<svg width="12" height="12">…</svg>
</button>
</div>
<div class="mu-preview-info">
<div class="mu-preview-name">cover-art.png</div>
<div class="mu-preview-size">2.4 MB · 1200 × 1200</div>
</div>
</div>
<!-- Audio preview -->
<div class="mu-preview-item">
<div class="mu-preview-icon">
<!-- Play icon -->
<svg width="16" height="16">…</svg>
</div>
<div class="mu-preview-info">
<div class="mu-preview-name">midnight-beat.wav</div>
<div class="mu-preview-size">18.2 MB · 3:42</div>
</div>
<button type="button" aria-label="Remove file" title="Remove file"
class="mu-remove-btn">
<svg width="12" height="12">…</svg>
</button>
</div>
<!-- Document preview -->
<div class="mu-preview-item">
<div class="mu-preview-icon">
<!-- File icon -->
<svg width="16" height="16">…</svg>
</div>
<div class="mu-preview-info">
<div class="mu-preview-name">license-agreement.pdf</div>
<div class="mu-preview-size">1.1 MB</div>
</div>
<button type="button" aria-label="Remove file" title="Remove file"
class="mu-remove-btn">
<svg width="12" height="12">…</svg>
</button>
</div>
<!-- Add more tile -->
<label aria-label="Add more files" class="media-upload"
style="padding: 24px;">
<input type="file" class="sr-only" />
<svg class="media-upload-icon" width="24" height="24">…</svg>
<span class="media-upload-hint">Add more</span>
</label>
Breakdown of the dropzone and preview elements.
Token-based values for colors, spacing, typography, and preview sizing.
| Type | Accepted formats | Max size | Constraints | Max files |
|---|---|---|---|---|
| Image | PNG, JPG, GIF, WebP | 10 MB | 1:1 or 16:9 aspect ratio | 1 |
| Audio | MP3, WAV, FLAC, AAC | 50 MB | Max duration 10 min | 1 |
| Document | PDF, DOC, DOCX, ZIP | 25 MB | - | 1 |
| Property | Token | Value |
|---|---|---|
| Dropzone | ||
| Padding | --space-8 |
32px |
| Border | --color-border-subtle |
2px dashed #333333 |
| Border radius | --radius-xl |
16px |
| Background | --color-bg-surface |
#1A1A1A |
| Hover border | --color-primary |
#6BDDFF |
| Drag-over background | - | rgba(107, 221, 255, 0.10) |
| Icon size | - | 32px |
| CTA text color | --color-primary |
#6BDDFF |
| Label font | - | 14px, 500 |
| Hint font | --text-xs |
12px |
| Hint color | --color-text-dim |
#888888 |
| Disabled opacity | - | 0.5 |
| Preview | ||
| Thumbnail size | - | 120 × 120px |
| Thumbnail radius | - | 12px |
| Remove button | - | 24px circle |
| Preview item gap | --space-3 |
12px |
| Progress | ||
| Progress height | --progress-height |
8px |
| Progress fill | --color-primary |
#6BDDFF |
| Progress track | --color-bg-surface |
#1A1A1A |
Best practices for validation, progress, and error recovery.
File types
Why: Showing explicit formats and size limits per upload type prevents invalid uploads and reduces frustration.Missing validation
Why: Accepting all file types without validation leads to server errors and forces users to guess what's allowed.Error recovery
Why: A specific reason ("File exceeds 10 MB limit") and a retry action let users recover without starting over.Silent failure
Why: Generic "Upload failed" messages without a reason or recovery path leave users stuck and erode trust.Per-file progress
Why: Per-file progress bars show users exactly what's happening during batch uploads and let them spot stalled files.Hidden progress
Why: Hiding per-file progress during batch uploads makes it impossible to know which files succeeded and which are stuck.A brief, contextual message that confirms an action, warns about a problem, or reports an error.
Transient messages for confirming actions or surfacing issues. Choose type based on urgency and required user attention.
<!-- Success notification: auto-dismisses after 5 s -->
<div class="notification notification-success"
role="status" aria-live="polite" aria-atomic="true"
aria-label="Success notification">
<!-- Icon: check-circle, 20 × 20, color-success -->
<svg class="notification-icon"
style="color: var(--color-success);"
width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<div class="notification-content">
<span class="notification-title">Product published</span>
<span class="notification-message">Midnight Drum Kit is now live on your store.</span>
</div>
<button type="button" class="notification-close"
aria-label="Dismiss notification" title="Dismiss notification">
<svg width="14" height="14" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
aria-hidden="true">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
Error toasts persist until manually dismissed. Use role="alert" for assertive announcement.
<!-- Error notification: NO auto-dismiss, manual close only -->
<div class="notification notification-error"
role="alert" aria-live="assertive" aria-atomic="true"
aria-label="Error notification">
<!-- Icon: x-circle, 20 × 20, color-error -->
<svg class="notification-icon"
style="color: var(--color-error);"
width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<path d="M15 9l-6 6M9 9l6 6"/>
</svg>
<div class="notification-content">
<span class="notification-title">Upload failed</span>
<span class="notification-message">File exceeds 50 MB limit. Please compress and try again.</span>
</div>
<button type="button" class="notification-close"
aria-label="Dismiss notification" title="Dismiss notification">
<svg width="14" height="14" aria-hidden="true">…</svg>
</button>
</div>
Warning toasts surface non-blocking issues. They auto-dismiss after 5 s but use assertive announcement.
<!-- Warning notification: auto-dismisses after 5 s -->
<div class="notification notification-warning"
role="alert" aria-live="assertive" aria-atomic="true"
aria-label="Warning notification">
<!-- Icon: alert-triangle, 20 × 20, color-warning -->
<svg class="notification-icon"
style="color: var(--color-warning);"
width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<div class="notification-content">
<span class="notification-title">Payment pending</span>
<span class="notification-message">Your payout is being processed.</span>
</div>
<button type="button" class="notification-close"
aria-label="Dismiss notification" title="Dismiss notification">
<svg width="14" height="14" aria-hidden="true">…</svg>
</button>
</div>
Informational toasts for neutral updates. Auto-dismiss after 5 s with polite announcement.
<!-- Info notification: auto-dismisses after 5 s -->
<div class="notification notification-info"
role="status" aria-live="polite" aria-atomic="true"
aria-label="Info notification">
<!-- Icon: info, 20 × 20, color-primary -->
<svg class="notification-icon"
style="color: var(--color-primary);"
width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
<div class="notification-content">
<span class="notification-title">New version available</span>
<span class="notification-message">BeatConnect 2.4 is ready to install.</span>
</div>
<button type="button" class="notification-close"
aria-label="Dismiss notification" title="Dismiss notification">
<svg width="14" height="14" aria-hidden="true">…</svg>
</button>
</div>
Notifications animate in/out, support stacking, and may pause auto-dismiss on hover.
Visual breakdown of the toast container and its elements.
Token-based sizing, typography, colors, spacing, and motion values.
| Property | Token | Value |
|---|---|---|
| Container | ||
| Max width | - | 360px |
| Padding | --space-4 |
16px |
| Border radius | --radius-lg |
12px |
| Background | --color-bg-elevated |
#111111 |
| Border | --color-border |
1px solid #2A2A2A |
| Elevation | --elevation-float |
0 6px 18px rgba(0, 0, 0, 0.35) |
| Accent | ||
| Accent width | - | 3px |
| Success accent | --color-success |
#22C55E |
| Error accent | --color-error |
#EF4444 |
| Warning accent | --color-warning |
#F59E0B |
| Info accent | --color-primary |
#6BDDFF |
| Typography | ||
| Title font | - | 14px, 500, --color-text (#EDEDED) |
| Message font | - | 13px, 400, --color-text-muted (#A1A1A1) |
| Icon size | - | 20px |
| Dismiss icon | - | 14px, --color-text-dim (#888888) |
| Gap (icon → content) | --space-3 |
12px |
| Position & stacking | ||
| Position | - | fixed, top-right |
| Offset from edge | --space-4 |
16px from top and right |
| Stack gap | --space-3 |
12px |
| Max visible | - | 3–5 |
| Motion | ||
| Enter | - | 200 ms ease-out (slide + fade) |
| Exit | - | 150 ms ease-in (slide + fade) |
| Auto-dismiss | ||
| Success / Info / Warning | - | 5 s |
| Error | - | Manual dismiss only |
| Hover behavior | - | Pauses auto-dismiss timer |
Best practices for duration, stacking limits, and when NOT to use a notification.
Persist errors, auto-dismiss success
Why: Error notifications must persist until manually dismissed so users have time to read and act on the message.Auto-dismiss errors
Why: Auto-dismissing error notifications hides critical information before the user can respond, causing confusion and repeated failures.Stacking limits
Why: Stacking newest on top with a 3-5 limit prevents notifications from overwhelming the viewport and burying important messages.Overlapping content
Why: Toasts overlapping primary content block interaction and obscure the information the user is actively working with.Non-blocking confirmations
Why: Offering Undo for destructive actions inside a toast gives users a safety net without interrupting their flow with a modal.Confirmation dialogs in toasts
Why: Putting multi-step confirmations inside a transient toast risks auto-dismissing before the user decides. Use a modal for decisions that require explicit consent.A small overlay that reveals supplementary text on hover or focus to clarify an element.
Tooltips can appear on four sides. Choose a placement that avoids covering the trigger or nearby UI.
<!-- Tooltip: Top placement -->
<div class="tooltip-demo">
<button aria-describedby="tooltip-1">Hover me</button>
<div id="tooltip-1" role="tooltip" class="tooltip-content tooltip-top">
Tooltip text
</div>
<!-- Arrow is rendered via .tooltip-content::before pseudo-element -->
</div>
<!-- Placement classes:
.tooltip-top → bottom: calc(100% + 8px) (tooltip above trigger)
.tooltip-bottom → top: calc(100% + 8px) (tooltip below trigger)
.tooltip-left → right: calc(100% + 8px) (tooltip left of trigger)
.tooltip-right → left: calc(100% + 8px) (tooltip right of trigger)
-->
Use tooltips to clarify icon-only actions or define terms. Tooltips supplement but never replace accessible names.
<!-- Icon button with tooltip -->
<div class="tooltip-demo">
<button class="btn btn-icon"
aria-label="Save to library"
aria-describedby="tooltip-save">
<svg width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
aria-hidden="true">
<path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2z"/>
</svg>
</button>
<div id="tooltip-save" role="tooltip"
class="tooltip-content tooltip-bottom">
Save to library
</div>
</div>
<!-- Term definition tooltip -->
<div class="tooltip-demo">
<span tabindex="0"
aria-describedby="tooltip-term"
style="border-bottom: 1px dashed var(--color-text-dim);
color: var(--color-text-muted); cursor: help;">
Royalty-free
</span>
<div id="tooltip-term" role="tooltip"
class="tooltip-content tooltip-top">
Use in unlimited projects without additional fees
</div>
</div>
<!-- Note: aria-label on icon buttons is required.
Tooltip does NOT replace aria-label. It supplements it
via aria-describedby for additional context.
Tooltip appears on hover AND keyboard focus.
Press Escape to dismiss. -->
Tooltips appear after a short delay on hover or focus, and dismiss on mouse leave, blur, or Escape.
Structural parts of the tooltip component.
Token-based colors/typography plus motion and sizing constraints for readable, consistent tooltips.
| Property | Token | Value |
|---|---|---|
| Appearance | ||
| Background | --color-bg-surface |
#1A1A1A |
| Border | --color-border-subtle |
1px solid #333333 |
| Text color | --color-text |
#EDEDED |
| Font size | --text-sm |
13px |
| Line height | - | 1.4 |
| Padding | --space-2, --space-3 |
8px 12px |
| Border radius | --radius-md |
8px |
| Elevation | --elevation-float |
0 6px 18px rgba(0, 0, 0, 0.35) |
| Sizing | ||
| Max width | - | 240px |
| White-space | - | normal (wraps) |
| Arrow size | - | 8px (rotated square via ::before) |
| Offset from trigger | - | 8px |
| Z-index | - | 50 |
| Motion | ||
| Show delay | - | 200 ms |
| Hide delay | - | 0 ms |
| Transition | - | opacity 150 ms ease |
| Dismiss | - | Escape key hides tooltip |
Best practices for accessibility, show timing, and when to use a popover instead.
Keyboard access
Why: Showing tooltips on hover and keyboard focus ensures all users can discover supplementary labels, including those navigating with a keyboard or assistive tech.Hover-only tooltips
Why: Hover-only tooltips are invisible to keyboard and touch users, making icon-only buttons inaccessible.Static text only
Why: Tooltips are for static, read-only text. Users expect them to disappear on mouse leave, so content must be immediately understandable.Interactive content
Why: Links, buttons, or forms inside a tooltip can't be reached before the tooltip disappears. Use a popover for interactive content.Short show delay
Why: A 200 ms delay prevents tooltips from flickering during incidental mouse movement while still feeling responsive when the user intends to hover.Instant show
Why: Showing tooltips instantly on every mouse movement creates a distracting flicker that obscures the UI and frustrates users.A dialog that blocks the page until the user completes a task or dismisses it.
Modals interrupt the current flow to confirm an action, display information, or collect focused input.
Are you sure you want to delete "Midnight Drum Kit"? This action cannot be undone and all associated sales data will be lost.
<!-- Backdrop: blocks interaction with page behind -->
<div style="position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px); z-index: 40;"></div>
<!-- Destructive confirmation: uses role="alertdialog" -->
<div style="position: fixed; inset: 0; display: flex;
align-items: center; justify-content: center;
padding: 16px; z-index: 50;">
<div class="modal-preview"
role="alertdialog" aria-modal="true"
aria-labelledby="modal-title-del"
aria-describedby="modal-desc-del">
<!-- Header -->
<div class="modal-header">
<h2 id="modal-title-del" class="modal-title">Delete Product</h2>
<button type="button" class="modal-close"
aria-label="Close dialog">
<svg width="16" height="16" aria-hidden="true">…</svg>
</button>
</div>
<!-- Body -->
<div class="modal-body">
<p id="modal-desc-del">Are you sure you want to delete
"Midnight Drum Kit"? This action cannot be undone and
all associated sales data will be lost.</p>
</div>
<!-- Footer -->
<div class="modal-footer">
<button class="btn btn-secondary">Cancel</button>
<button class="btn btn-destructive">Delete</button>
</div>
</div>
</div>
Standard confirmation for non-destructive actions like publishing or archiving.
"Midnight Drum Kit" will be visible to all users on BeatConnect. You can unpublish anytime from the product settings.
<!-- Backdrop -->
<div style="position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px); z-index: 40;"></div>
<!-- Standard confirmation: uses role="dialog" -->
<div style="position: fixed; inset: 0; display: flex;
align-items: center; justify-content: center;
padding: 16px; z-index: 50;">
<div class="modal-preview"
role="dialog" aria-modal="true"
aria-labelledby="modal-title-pub"
aria-describedby="modal-desc-pub">
<div class="modal-header">
<h2 id="modal-title-pub" class="modal-title">Publish Product</h2>
<button type="button" class="modal-close"
aria-label="Close dialog">
<svg width="16" height="16" aria-hidden="true">…</svg>
</button>
</div>
<div class="modal-body">
<p id="modal-desc-pub">"Midnight Drum Kit" will be visible
to all users on BeatConnect. You can unpublish anytime
from the product settings.</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary">Cancel</button>
<button class="btn btn-primary">Publish</button>
</div>
</div>
</div>
Informational modal for announcements or alerts that require acknowledgment.
Analytics dashboards now support real-time data. Visit your Analytics page to see live sales and visitor counts as they happen.
<!-- Backdrop -->
<div style="position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px); z-index: 40;"></div>
<!-- Information modal -->
<div style="position: fixed; inset: 0; display: flex;
align-items: center; justify-content: center;
padding: 16px; z-index: 50;">
<div class="modal-preview"
role="dialog" aria-modal="true"
aria-labelledby="modal-title-info"
aria-describedby="modal-desc-info">
<div class="modal-header">
<h2 id="modal-title-info" class="modal-title">New Feature Available</h2>
<button type="button" class="modal-close"
aria-label="Close dialog">
<svg width="16" height="16" aria-hidden="true">…</svg>
</button>
</div>
<div class="modal-body">
<p id="modal-desc-info">Analytics dashboards now support
real-time data. Visit your Analytics page to see live
sales and visitor counts as they happen.</p>
</div>
<div class="modal-footer">
<button class="btn btn-primary">Got it</button>
</div>
</div>
</div>
Form modals collect focused input. Uses medium width (560 px) for additional form fields.
Group related products into a collection to feature on your storefront.
<!-- Backdrop -->
<div style="position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px); z-index: 40;"></div>
<!-- Form modal: medium width (560 px) -->
<div style="position: fixed; inset: 0; display: flex;
align-items: center; justify-content: center;
padding: 16px; z-index: 50;">
<div class="modal-preview-md"
role="dialog" aria-modal="true"
aria-labelledby="modal-title-coll"
aria-describedby="modal-desc-coll">
<div class="modal-header">
<h2 id="modal-title-coll" class="modal-title">Create Collection</h2>
<button type="button" class="modal-close"
aria-label="Close dialog">
<svg width="16" height="16" aria-hidden="true">…</svg>
</button>
</div>
<div class="modal-body">
<p id="modal-desc-coll">Group related products into a
collection to feature on your storefront.</p>
<div style="display: flex; flex-direction: column; gap: 12px;
margin-top: 16px;">
<div>
<label style="font-size: 13px; font-weight: 500;">
Collection name</label>
<input type="text" placeholder="e.g., Summer Beats 2025"
style="width: 100%; padding: 8px 12px;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 8px;" />
</div>
<div>
<label style="font-size: 13px; font-weight: 500;">
Description</label>
<textarea rows="3" placeholder="Describe…"
style="width: 100%; padding: 8px 12px;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 8px;"></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary">Cancel</button>
<button class="btn btn-primary">Create</button>
</div>
</div>
</div>
Required focus, keyboard, and scroll behaviors for accessible modal implementation.
| Behavior | Requirement |
|---|---|
| Focus trap | Tab / Shift+Tab cycles within modal; focus never escapes to background content. |
| Initial focus | First focusable element inside modal, or close button if body has no interactive elements. |
| Return focus | On close, restore focus to the element that triggered the modal. |
| Escape key | Closes the modal. |
| Backdrop click | Non-destructive: closes modal. Destructive (alertdialog): does NOT close on backdrop click. |
| Scroll lock | Disable document/body scroll while modal is open. |
| Body scroll | For long content, modal body scrolls internally; header and footer remain fixed. |
Prefer native <dialog> + showModal()/close() where browser support and styling constraints allow. Native <dialog> provides built-in focus trap, Escape handling, and top-layer rendering. Otherwise, implement equivalent behavior manually.
Modal lifecycle from closed through open, including motion and overflow behavior.
A modal includes a backdrop, dialog surface, header, body, and footer actions.
Use tokens for sizing, color, and spacing to keep dialogs consistent across the system.
| Property | Token | Value |
|---|---|---|
| Surface | ||
| Width (sm) | --modal-width-sm |
400px |
| Width (md) | --modal-width-md |
560px |
| Background | --color-bg-elevated |
#111111 |
| Border | --color-border |
1px solid #2A2A2A |
| Border radius | --radius-xl |
16px |
| Elevation | --elevation-overlay |
0 12px 32px rgba(0, 0, 0, 0.40) |
| Z-index | - | 50 |
| Viewport inset | --space-4 |
min 16px from edges |
| Backdrop | ||
| Background | - | rgba(0, 0, 0, 0.6) |
| Blur | - | backdrop-filter: blur(4px) |
| Z-index | - | 40 |
| Header & Footer | ||
| Header padding | --space-4, --space-5 |
16px 20px |
| Footer padding | --space-4, --space-5 |
16px 20px |
| Footer button gap | --space-3 |
12px |
| Divider | --color-border |
1px solid #2A2A2A |
| Typography | ||
| Title font | - | 16px, 600, --color-text (#EDEDED) |
| Body font | - | 14px, 400, --color-text-muted (#A1A1A1) |
| Body padding | --space-5 |
20px |
| Close button | - | 28 × 28px; icon 16px |
| Motion | ||
| Enter | - | 200 ms ease-out (fade + scale from 0.95) |
| Exit | - | 150 ms ease-in (fade + scale to 0.97) |
Keep one task per modal and enforce accessible focus behavior.
Focus management
Why: Trapping focus inside the modal and returning it to the trigger on close ensures keyboard and screen reader users never lose their place in the page.Focus escapes to background
Why: Allowing Tab to escape to background content while a modal is open lets users interact with obscured elements, causing confusion and potential data loss.One task per modal
Why: A focused, single-task modal is easy to scan and act on. Users make faster decisions when the choice is clear.Multi-step wizard in modal
Why: Multi-step flows in a modal overwhelm users with dense content and offer no way to save progress. Use a dedicated page instead.Close before opening another
Why: Closing one modal before opening another preserves a clear mental model and avoids confusing z-index / focus-trap layering.Stacking modals
Why: Stacking modals creates nested focus traps, confuses Escape-key behavior, and overwhelms users with layered context they can't track.The top-level frame that positions navigation, sidebar, and content area for each page type.
Portal shell with fixed sidebar and scrollable main content. Used for Creator Portal.
<div class="flex min-h-screen bg-zinc-950">
<!-- Fixed sidebar -->
<aside class="w-64 bg-zinc-900 fixed inset-y-0 left-0">...</aside>
<!-- Main content area -->
<main class="flex-1 ml-64 p-8">
<!-- Breadcrumb -->
<nav>...</nav>
<!-- Page header -->
<header>...</header>
<!-- Page content -->
<div>...</div>
</main>
</div>
Marketing shell with top navigation and centered content. Used for public-facing pages.
<div class="min-h-screen bg-zinc-950 flex flex-col">
<!-- Fixed top navigation -->
<header class="sticky top-0 border-b border-zinc-800 px-6 py-4">
<nav class="max-w-6xl mx-auto flex justify-between items-center">...</nav>
</header>
<!-- Centered content -->
<main class="flex-1 max-w-6xl mx-auto px-6 py-12">...</main>
<!-- Footer -->
<footer class="border-t border-zinc-800 px-6 py-8">...</footer>
</div>
| Property | Portal | Marketing |
|---|---|---|
| Sidebar width | 260px | n/a |
| Max content width | Fluid | 1280px |
| Content padding | 32px | 24px horizontal |
| Navigation | Fixed left sidebar | Sticky top header |
Portal shell
Why: Sidebar navigation works best when users need quick access to many sections.Marketing shell
Why: Top navigation keeps the focus on content and CTAs.The banner at the top of every page that identifies the section and houses primary actions.
Basic page header with title and description. Used on content pages that don't require actions or sub-navigation.
Manage your sample kits, plugins, and preset packs.
<header class="page-header-wrapper">
<h1 class="page-header-title">Products</h1>
<p class="page-header-desc">Manage your sample kits, plugins, and preset packs.</p>
</header>
<!-- Tokens used:
Title → var(--text-2xl) 24px, 700, var(--color-text)
Desc → var(--text-sm) 14px, 400, var(--color-text-muted)
Gap → var(--space-1) 4px (title → desc)
Bottom → var(--space-8) 32px (header → content)
-->
Page header with action buttons aligned to the right. Use for pages where the primary action is contextual to the page content.
12 kits published
<header class="page-header-wrapper page-header-with-actions">
<div>
<h1 class="page-header-title">Sample Kits</h1>
<p class="page-header-desc">12 kits published</p>
</div>
<div class="page-header-actions">
<button class="btn btn-secondary btn-sm">Export</button>
<button class="btn btn-primary btn-sm">+ Add Kit</button>
</div>
</header>
<!-- .page-header-with-actions → display: flex; justify-content: space-between; align-items: flex-start
.page-header-actions → display: flex; gap: var(--space-2)
Buttons use full class chain: btn btn-secondary btn-sm / btn btn-primary btn-sm -->
Page header with navigation tabs for sub-sections. Combine with actions and badges for complex page layouts.
Manage your account and preferences.
<header class="page-header-wrapper page-header-with-tabs">
<div class="page-header-row">
<div>
<h1 class="page-header-title">Settings</h1>
<p class="page-header-desc">Manage your account and preferences.</p>
</div>
<span class="badge badge-success">Saved</span>
</div>
<nav role="tablist" class="page-header-tabs" aria-label="Settings sections">
<button role="tab" aria-selected="true" class="page-header-tab active">Profile</button>
<button role="tab" aria-selected="false" class="page-header-tab" tabindex="-1">Storefront</button>
<button role="tab" aria-selected="false" class="page-header-tab" tabindex="-1">Payments</button>
<button role="tab" aria-selected="false" class="page-header-tab" tabindex="-1">Notifications</button>
</nav>
</header>
<!-- Tokens used:
Tab active → color: var(--color-primary); border-bottom: 2px solid var(--color-primary)
Tab inactive → color: var(--color-text-muted)
Tab font → var(--text-sm) 13px, 500
Tab gap → var(--space-6) 24px
Tab padding → padding-bottom: var(--space-3) 12px
Border → 1px solid var(--color-border) #2A2A2A
Header → tabs gap → var(--space-5) 20px
Bottom margin → var(--space-6) 24px (header+tabs → content)
NOTE: System --text-sm = 13px. Tailwind text-sm = 14px.
Use the system token in production code. -->
Structural breakdown of a page header with all optional elements visible.
| Property | Token | Value |
|---|---|---|
| Typography | ||
| Title font size | --text-2xl | 24px |
| Title font weight | - | 700 |
| Title color | --color-text | #FFFFFF |
| Description font size | --text-sm | 14px ⚠ |
| Description font weight | - | 400 |
| Description color | --color-text-muted | #A1A1AA |
⚠ Tailwind text-sm = 14px. System --text-sm = 13px. The live preview uses 14px to match the Tailwind reference. Verify which token your codebase uses. | ||
| Spacing | ||
| Title → description | --space-1 | 4px |
| Header → content | --space-8 | 32px |
| Actions button gap | --space-2 | 8px |
| Header → tabs | --space-5 | 20px |
| With Tabs bottom margin | --space-6 | 24px |
| Tab Bar | ||
| Tab font size | --text-sm | 13px |
| Tab font weight | - | 500 |
| Tab gap | --space-6 | 24px |
| Tab padding bottom | --space-3 | 12px |
| Active tab color | --color-primary | #00CFFD |
| Active tab border | --color-primary | 2px solid |
| Inactive tab color | --color-text-muted | #A1A1AA |
| Tab bar border | --color-border | 1px solid #2A2A2A |
Use noun-based page titles
Why: Clear, scannable titles help users confirm they're in the right place. Noun-based titles align with navigation labels.Greetings or action verbs as titles
Why: Greeting-style titles add noise and don't match the nav item the user clicked.Keep descriptions concise and actionable
Why: Short descriptions give context without competing with the page content below.Long descriptions that duplicate content
Why: Verbose descriptions push content below the fold and repeat what the page already shows.Right-align actions beside the title
Why: A consistent action position creates a predictable scanning pattern across all pages.Actions below the header
Why: Actions below the title break the header's visual hierarchy and compete with page content.A configuration layout that groups preferences and account controls into clear sections.
A standard settings layout with header, tabs, and stacked section cards.
Manage your account and preferences.
A working example showing how components combine into a complete settings page. See each component section for implementation details.
<!-- Page Header (see Page Header for full token docs) -->
<header class="page-header-wrapper">
<h1 class="page-header-title">Settings</h1>
<p class="page-header-desc">Manage your account and preferences.</p>
</header>
<!-- Tab Navigation (see Tabs for full a11y docs) -->
<nav role="tablist" class="page-header-tabs" aria-label="Settings sections">
<button role="tab" id="tab-profile"
aria-selected="true" aria-controls="panel-profile">Profile</button>
<button role="tab" id="tab-storefront"
aria-selected="false" aria-controls="panel-storefront" tabindex="-1">Storefront</button>
<button role="tab" id="tab-payments"
aria-selected="false" aria-controls="panel-payments" tabindex="-1">Payments</button>
</nav>
<!-- Tab Panel -->
<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">
<form action="#" method="post">
<!-- Section Card: Profile -->
<div class="settings-card">
<!-- Section Header (see Section Header) -->
<div class="section-header">
<div class="section-header-icon">…</div>
<div>
<h3 class="section-header-title">Profile Information</h3>
<p class="section-header-desc">Your public profile details.</p>
</div>
</div>
<!-- Side-by-side fields -->
<div class="settings-field-row">
<div class="input-wrapper">
<label class="input-label" for="display-name">Display Name</label>
<input id="display-name" type="text" class="input" value="Yohan T." />
</div>
<div class="input-wrapper">
<label class="input-label" for="store-url">Store URL</label>
<div class="input-group">
<span id="store-url-prefix" class="input-prefix">beatconnect.com/</span>
<input id="store-url" type="text" class="input"
aria-describedby="store-url-prefix" value="yohan" />
</div>
</div>
</div>
<!-- Full-width field -->
<div class="input-wrapper">
<label class="input-label" for="bio">Bio</label>
<textarea id="bio" class="textarea">Producer and sound designer…</textarea>
</div>
</div>
<!-- More section cards follow the same structure… -->
<!-- Action Bar -->
<div class="settings-actions">
<button type="submit" class="btn btn-primary btn-sm">Save Changes</button>
</div>
</form>
</div>
<!-- Input with Prefix pattern:
Use aria-describedby on the <input> pointing to the prefix span's id
so screen readers announce the prefix context when the input is focused.
See Input component for base field styling. -->
<!-- Tokens used:
.settings-card → background: rgba(255,255,255,0.03); border-radius: 12px;
padding: 20px; margin-bottom: 16px
.settings-field-row → display: grid; grid-template-columns: 1fr 1fr; gap: 12px
.settings-actions → display: flex; justify-content: flex-end
-->
Full page composition showing how components stack within the settings pattern.
| Property | Token / Class | Value |
|---|---|---|
| Section Card | ||
| Background | rgba(255,255,255,0.03) | ~3% white |
| Border radius | rounded-xl | 12px |
| Padding | p-5 | 20px |
| Card-to-card gap | mb-4 | 16px |
| Form Layout | ||
| Field vertical gap | space-y-4 / gap: 16px | 16px |
| Side-by-side field gap | gap-3 | 12px |
| Section header → fields | mb-5 | 20px |
| Input height (compact) | - | 36px |
| Label font size | - | 12px |
| Page Layout | ||
| Header → tabs | --space-6 | 24px |
| Tabs → first card | --space-6 | 24px |
| Action bar alignment | flex justify-end | right-aligned |
| Max content width | max-w-xl | 560px |
Best practices for field organization, save behavior, and validation in settings pages.
Group fields into section cards
Why: Sections help users scan and find the settings they need. Clear headers reduce cognitive load.Ungrouped field list
Why: Ungrouped fields overwhelm users and make it hard to find specific settings.Show success feedback and disable Save until changes
Why: Confirmation reassures users their changes persisted. Disabling Save prevents redundant submissions.Navigate-away on save
Why: Navigating away forces users to re-find their place if they need to change another setting.Validate inline and block save until resolved
Why: Inline errors let users fix problems in context without hunting for which field failed.Errors only after clicking Save
Why: Batch error banners at the top disconnect the message from the field, forcing users to scroll and hunt.Patterns that arrange labels, inputs, and buttons into clear, readable forms.
Single-column layout where fields stack vertically. Best for authentication, sign-up, and compact settings forms.
<form action="/api/login" method="post"
class="form-stacked"
style="max-width: var(--form-max-width-sm)">
<div class="input-wrapper">
<label class="input-label" for="email">Email</label>
<input class="input" id="email" name="email"
type="email" autocomplete="email" required />
</div>
<div class="input-wrapper">
<label class="input-label" for="password">Password</label>
<input class="input" id="password" name="password"
type="password" autocomplete="current-password" required />
</div>
<label class="form-checkbox">
<input type="checkbox" name="remember" />
<span>Remember me</span>
</label>
<button type="submit" class="btn btn-primary">Sign In</button>
</form>
<!-- .form-stacked → display: flex; flex-direction: column; gap: var(--space-4)
--form-max-width-sm → 480px (auth / compact forms)
--form-max-width-lg → 640px (settings / longer forms)
-->
Side-by-side layout with input and action button in one row. Best for short forms in tight spaces like search bars and newsletter sign-ups.
<form action="/api/search" method="get"
style="max-width: var(--form-max-width-sm)">
<div class="form-inline">
<div class="input-wrapper" style="flex: 1; min-width: 200px">
<label class="input-label" for="search">Search</label>
<input class="input" id="search" name="query"
type="search" placeholder="Search products…" />
</div>
<button type="submit" class="btn btn-primary">Search</button>
</div>
</form>
<!-- .form-inline → display: flex; gap: var(--space-3); align-items: flex-end; flex-wrap: wrap
On narrow screens the button wraps below the input naturally via flex-wrap. -->
Multi-column grid for complex forms with many fields. Collapses to single column on mobile. Use for settings, profiles, and billing forms.
<form action="/api/settings/profile" method="post"
class="form-grid"
style="max-width: var(--form-max-width-lg)">
<fieldset class="form-grid-fields">
<legend class="form-legend">Personal information</legend>
<div class="input-wrapper">
<label class="input-label" for="first-name">First Name</label>
<input class="input" id="first-name" name="first_name" type="text" />
</div>
<div class="input-wrapper">
<label class="input-label" for="last-name">Last Name</label>
<input class="input" id="last-name" name="last_name" type="text" />
</div>
<div class="input-wrapper col-span-2">
<label class="input-label" for="email">Email</label>
<input class="input" id="email" name="email" type="email" />
</div>
<div class="input-wrapper">
<label class="input-label" for="city">City</label>
<input class="input" id="city" name="city" type="text" />
</div>
<div class="input-wrapper">
<label class="input-label" for="country">Country</label>
<select class="select" id="country" name="country">
<option value="CA">Canada</option>
<option value="US">United States</option>
</select>
</div>
</fieldset>
<div class="form-actions col-span-2">
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
<!-- .form-grid → display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-4)
Responsive: @media (max-width: 640px) { grid-template-columns: 1fr }
.form-grid-fields inherits grid and resets fieldset styling
.form-legend → font-size: 13px; color: var(--color-text-muted)
.form-actions → display: flex; justify-content: flex-end; padding-top: var(--space-2)
-->
Common form field states with their required ARIA attributes and visual treatment.
Must be at least 8 characters.
<input aria-describedby="pw-help" />
<p id="pw-help">Must be 8+ chars.</p>
<label for="email">Email <span>*</span></label>
<input id="email" required aria-required="true" />
<!-- Explain asterisk once above the form -->
Please enter a valid email address.
<input aria-invalid="true"
aria-describedby="email-error" />
<p id="email-error" role="alert">
Please enter a valid email.
</p>
<form aria-busy="true">
<input disabled />
<button type="submit" disabled>
Submitting…
</button>
</form>
<div role="alert" aria-live="assertive">
<strong>2 errors found:</strong>
<ul>
<li><a href="#email">Email</a>: Please enter a valid email.</li>
<li><a href="#password">Password</a>: Must be 8+ chars.</li>
</ul>
</div>
<!-- On submit failure: focus moves to this summary.
Each link targets the invalid field's id. -->
Required ARIA patterns and keyboard behaviors for accessible forms.
<!-- 1. Group related fields with fieldset + legend -->
<fieldset>
<legend class="form-legend">Personal information</legend>
…fields…
</fieldset>
<!-- 2. Required fields: use both native + ARIA -->
<label for="email">Email <span aria-hidden="true">*</span></label>
<input id="email" required aria-required="true" />
<!-- 3. Error state: pair aria-invalid with describedby -->
<input id="email" aria-invalid="true" aria-describedby="email-error" />
<p id="email-error" role="alert">Invalid email.</p>
<!-- 4. Helper + error combined -->
<input aria-describedby="pw-help pw-error" />
<p id="pw-help">Must be 8+ characters.</p>
<p id="pw-error" role="alert">Too short.</p>
<!-- 5. Submitting state -->
<form aria-busy="true">
<input disabled />
<button type="submit" disabled>Submitting…</button>
</form>
<!-- 6. Focus management on submit failure:
Move focus to error summary or first invalid field.
Do NOT rely on placeholder as a label substitute. -->
Structural breakdown of a form showing all optional slots within a field group.
How each layout variant adapts to narrow viewports.
| Variant | Desktop | Mobile (< 640px) |
|---|---|---|
| Stacked | Single column, max-width: 480px | No change, already single column |
| Inline | Input + button side-by-side | Button wraps below input (flex-wrap) |
| Grid | 2 columns, max-width: 640px | Collapses to 1 column (grid-cols-1) |
| Property | Token | Value |
|---|---|---|
| Spacing | ||
| Field gap (vertical) | --space-4 | 16px |
| Label → input gap | --space-2 | 8px |
| Grid cell gap | --space-4 | 16px |
| Inline row gap | --space-3 | 12px |
| Actions top spacing | --space-2 | 8px (padding-top on action row) |
| Width | ||
| Max width (auth / compact) | --form-max-width-sm | 480px |
| Max width (settings / long) | --form-max-width-lg | 640px |
| Grid Variant | ||
| Columns (desktop) | grid-cols-2 | 2 columns |
| Columns (mobile) | grid-cols-1 | 1 column (< 640px) |
| Full-width fields | col-span-2 | spans both columns |
| Validation | ||
| Error border | #EF4444 | red-500 |
| Error text | #EF4444 | 12px, paired with icon |
| Helper text | --color-text-dim | 11px |
Best practices for form layout, labeling, validation, and responsive behavior.
Pair related fields side-by-side
Why: Grouping related fields reduces visual complexity and improves scan speed.Overstretched form width
Why: Fields beyond 640px force long eye sweeps and make input hard to scan.Always use visible labels
Why: Labels remain visible after input. Placeholders disappear and fail accessibility checks.Placeholder as label
Why: Placeholder text vanishes on focus, leaving users guessing what the field was for.Mark required fields and pair errors with icons
Why: Asterisks set expectations. Error icons ensure the message isn't missed by color-blind users.Color-only error styling
Why: A red border alone is invisible to color-blind users and provides no actionable guidance.The fallback screen shown when there's no content, explaining why and how to add some.
Full empty state with icon, title, description, and CTA. Use for first-time or onboarding empty content areas where the user can resolve the state.
Upload your first sample kit or plugin to start selling.
<div class="empty-state">
<div class="empty-state-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="M9 17H5a2 2 0 00-2 2 …" />
</svg>
</div>
<h3 class="empty-state-title">No products yet</h3>
<p class="empty-state-desc">Upload your first sample kit or plugin to start selling.</p>
<button type="button" class="btn btn-primary btn-sm">+ Add Product</button>
</div>
<!-- Tokens:
.empty-state → text-align: center; padding: 40px 24px; max-width: 360px; margin: 0 auto
.empty-state-icon → width: 48px; height: 48px; border-radius: 12px;
background: rgba(107,221,255,0.15); [primary @ 15%]
margin: 0 auto 12px; display: flex; align-items: center; justify-content: center
svg color: var(--color-primary)
.empty-state-title → font-size: 16px; font-weight: 600; color: var(--color-text); margin-bottom: 8px
.empty-state-desc → font-size: 14px; color: var(--color-text-muted); margin-bottom: 20px
-->
Compact empty state for tight containers like table bodies, sidebar lists, or card interiors where a CTA is not available.
No items to display
<div class="empty-state-minimal">
<p style="color: var(--color-text-dim)">No items to display</p>
</div>
<!-- .empty-state-minimal → text-align: center; padding: 32px 16px
Text: font-size: 14px; color: var(--color-text-dim) -->
Shown after a search or filter returns nothing. Must use role="status" and aria-live="polite" so screen readers announce the change.
Try adjusting your search or filters.
<!-- role="status" + aria-live required for dynamic empty states -->
<div class="empty-state" role="status" aria-live="polite" aria-atomic="true">
<div class="empty-state-icon empty-state-icon-subtle">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
</div>
<h3 class="empty-state-title">No results found</h3>
<p class="empty-state-desc">Try adjusting your search or filters.</p>
<button type="button" class="btn btn-ghost btn-sm">Clear Filters</button>
</div>
<!-- .empty-state-icon-subtle → width: 40px; height: 40px; border-radius: 10px;
background: rgba(255,255,255,0.05); svg color: var(--color-text-dim)
Used for utility/no-results empty states (not onboarding) -->
Choose the right variant based on context and user capability.
| Variant | Context | ARIA |
|---|---|---|
| Default | First-time / onboarding empty areas. User can resolve by adding content. | None required (static on load) |
| No Results | Search or filter returns nothing. Content existed but is filtered out. | role="status" aria-live="polite" aria-atomic="true" |
| Minimal | Tight containers (table body, sidebar list, card). No CTA available. | None required (context is clear) |
Structural breakdown of a default empty state. Icon and CTA are optional depending on variant.
ARIA requirements for dynamically shown empty states and CTA buttons.
<!-- 1. Dynamic empty states (after search/filter) -->
<div role="status" aria-live="polite" aria-atomic="true">
<h3>No results found</h3>
<p>Try adjusting your search or filters.</p>
<button type="button" class="btn btn-ghost btn-sm">Clear Filters</button>
</div>
<!-- Screen readers announce the full region when content changes. -->
<!-- 2. Static empty states (on initial page load) -->
<div class="empty-state">
<h3>No products yet</h3>
<p>Upload your first sample kit…</p>
<button type="button" class="btn btn-primary btn-sm">+ Add Product</button>
</div>
<!-- No role/live needed, content is present on load. -->
<!-- 3. CTA button naming -->
<!-- Use descriptive action labels: "+ Add Product", "Upload Kit"
Avoid ambiguous labels like "Add" or "Start". -->
<!-- 4. Decorative icons must be aria-hidden -->
<svg aria-hidden="true">…</svg>
| Property | Token / Value | Default | No Results | Minimal |
|---|---|---|---|---|
| Icon | ||||
| Container size | - | 48 × 48px | 40 × 40px | none |
| Container radius | --radius-lg | 12px | 10px | - |
| Background | - | rgba(107,221,255,0.15) | rgba(255,255,255,0.05) | - |
| SVG color | - | var(--color-primary) | var(--color-text-dim) | - |
| Icon → title gap | --space-3 | 12px | 12px | - |
| Typography | ||||
| Title | --text-base, 600 | 16px | 16px | - |
| Title color | --color-text | #EDEDED | #EDEDED | - |
| Description | --text-sm, 400 | 14px | 14px | 14px |
| Description color | - | var(--color-text-muted) | var(--color-text-muted) | var(--color-text-dim) |
| Spacing | ||||
| Title → description | --space-2 | 8px | 4px | - |
| Description → CTA | --space-5 | 20px | 16px | - |
| Container padding | - | 40px 24px | 40px 24px | 32px 16px |
| Max width | - | 360px | 320px | - |
Best practices for empty state content, icons, and actions.
Provide a clear action when the user can resolve the state
Why: A visible CTA transforms a dead end into a starting point. Users know exactly what to do next.Blank page with no explanation
Why: Blank screens leave users confused about whether content is loading, missing, or broken.Use a relevant icon for the content type
Why: Content-specific icons reinforce what the area is for. Generic icons don't communicate purpose.Generic copy
Why: Vague copy doesn't tell users what belongs here or how to populate it.Keep description to 1-2 short sentences
Why: Concise descriptions are scannable. Users just need enough context to understand and act.Multiple competing actions
Why: Multiple CTAs create decision paralysis. One clear action gets the user moving.A dashboard layout that presents key metrics in a compact grid for fast scanning.
Four stat cards showing positive, negative, and neutral change states. Uses definition list markup (<dl>) so screen readers announce each metric as a label and value.
<!-- Responsive: grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 -->
<dl class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Positive change -->
<div class="stat-card bg-white/4 rounded-xl p-5" role="group" aria-label="Total Revenue">
<dt class="stat-label text-xs text-dim mb-1">Total Revenue</dt>
<dd class="stat-value text-[28px] font-bold text-primary-text">$12,489</dd>
<dd class="stat-change text-xs text-positive mt-1">
<span aria-hidden="true">↑</span>
<span class="sr-only">Increased by</span> 12.5%
</dd>
</div>
<!-- Positive change -->
<div class="stat-card bg-white/4 rounded-xl p-5" role="group" aria-label="Total Sales">
<dt class="stat-label text-xs text-dim mb-1">Total Sales</dt>
<dd class="stat-value text-[28px] font-bold text-primary-text">847</dd>
<dd class="stat-change text-xs text-positive mt-1">
<span aria-hidden="true">↑</span>
<span class="sr-only">Increased by</span> 8.2%
</dd>
</div>
<!-- Negative change -->
<div class="stat-card bg-white/4 rounded-xl p-5" role="group" aria-label="Page Views">
<dt class="stat-label text-xs text-dim mb-1">Page Views</dt>
<dd class="stat-value text-[28px] font-bold text-primary-text">24.3k</dd>
<dd class="stat-change text-xs text-negative mt-1">
<span aria-hidden="true">↓</span>
<span class="sr-only">Decreased by</span> 3.1%
</dd>
</div>
<!-- Neutral (no change) -->
<div class="stat-card bg-white/4 rounded-xl p-5" role="group" aria-label="Conversion Rate">
<dt class="stat-label text-xs text-dim mb-1">Conversion</dt>
<dd class="stat-value text-[28px] font-bold text-primary-text">3.2%</dd>
<dd class="stat-change text-xs text-dim mt-1">
<span class="sr-only">No change:</span> 0%
</dd>
</div>
</dl>
<!-- Token note: --stat-value-size is 28px.
Tailwind text-3xl (30px) ≠ system --text-3xl (32px).
Use text-[28px] or the component token. -->
Show skeleton placeholders while stat data is loading. Match the exact card layout so content doesn't shift on hydration.
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"
aria-busy="true" aria-label="Loading stats">
<div class="stat-card bg-white/4 rounded-xl p-5" aria-hidden="true">
<div class="skeleton skeleton-text w-20 h-3 mb-2"></div>
<div class="skeleton skeleton-text w-24 h-7 mb-2"></div>
<div class="skeleton skeleton-text w-12 h-3"></div>
</div>
<!-- Repeat × 4 -->
</div>
When data is unavailable, show a clear placeholder instead of hiding the grid.
Structural breakdown of a single stat card and the containing grid.
Screen reader and keyboard considerations for stat grids.
<!-- 1. Semantic markup: use <dl> for term/value pairs -->
<dl class="stat-grid">
<div role="group" aria-label="Total Revenue">
<dt>Total Revenue</dt>
<dd>$12,489</dd>
<dd>
<!-- 2. Arrow icons are decorative → aria-hidden -->
<span aria-hidden="true">↑</span>
<!-- 3. sr-only text conveys direction to screen readers -->
<span class="sr-only">Increased by</span> 12.5%
</dd>
</div>
</dl>
<!-- 4. Skeleton loading: aria-busy + aria-label -->
<dl class="stat-grid" aria-busy="true" aria-label="Loading stats">
<div aria-hidden="true">…skeleton…</div>
</dl>
<!-- 5. No-data state: use neutral text, not color alone -->
<dd>-</dd>
<dd>No data</dd>
<!-- 6. Number formatting: use locale-aware formatting
so screen readers announce "$12,489" correctly.
Avoid abbreviations like "12.5k" without sr-only
expanded text. -->
All dimensions reference component tokens so sizes stay consistent across themes.
| Property | Token | Value |
|---|---|---|
| Layout | ||
| Columns (desktop ≥ 1024px) | lg:grid-cols-4 | 4 |
| Columns (tablet ≥ 640px) | sm:grid-cols-2 | 2 |
| Columns (mobile) | grid-cols-1 | 1 |
| Gap | --space-4 |
16px |
| Card | ||
| Background | bg-white/4 | rgba(255,255,255, 0.04) |
| Border radius | --radius-xl |
12px |
| Padding | --space-5 |
20px |
| Typography | ||
| Value size | --stat-value-size |
28px |
| Value weight | --font-bold |
700 |
| Label size | --text-xs |
12px |
| Label color | --color-text-dim |
#888888 |
| Change size | --text-xs |
12px |
| Color: change indicator | ||
| Positive (↑) | --color-positive |
#22C55E |
| Negative (↓) | --color-negative |
#EF4444 |
| Neutral (0%) | --color-text-dim |
#888888 |
text-3xl = 30px; system --text-3xl = 32px. The stat value uses --stat-value-size (28px) to avoid ambiguity.
Show 3–5 key metrics
Why: Focus on actionable numbers users check regularly. Fewer cards keep the dashboard scannable.Dump 10+ stats without prioritization
Why: Too many metrics dilute attention and reduce scannability. Prioritize or paginate.Use semantic color for change direction
Why: Green/red + arrow provides two redundant cues (color + icon), meeting WCAG 1.4.1 Use of Color.Rely on color alone for direction
Why: Without arrows or labels, colorblind users can't distinguish positive from negative changes.Format numbers for scannability
Why: Use locale-aware separators and abbreviations (k, M) to keep values compact. For abbreviated values, provide full numbers in a tooltip orsr-only text.Show raw unformatted numbers
Why: Raw numbers take longer to parse and are harder to compare at a glance.A responsive grid layout that presents a collection of cards in a uniform, repeatable structure.
Grid of product cards with image, title, and price. Each card is an <article> wrapped in a clickable <a>.
<!-- Responsive: grid-cols-1 md:grid-cols-2 lg:grid-cols-3 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Card 1: gradient placeholder -->
<a href="/products/midnight-drums" class="group block bg-white/[0.04] rounded-xl overflow-hidden
hover:bg-white/[0.06] transition-colors focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-primary">
<article>
<div class="h-[120px] bg-gradient-to-br from-[var(--color-primary)] to-purple-500"
role="img" aria-label="Midnight Drums cover art"></div>
<div class="p-4">
<h3 class="text-sm font-semibold text-primary-text mb-1 truncate">Midnight Drums</h3>
<p class="text-xs text-dim mb-2">Sample Kit · 45 files</p>
<span class="text-sm font-semibold text-primary">$29</span>
</div>
</article>
</a>
<!-- Card 2: real image -->
<a href="/products/lofi-essentials" class="group block bg-white/[0.04] rounded-xl overflow-hidden
hover:bg-white/[0.06] transition-colors focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-primary">
<article>
<img src="/images/products/lofi-essentials.jpg" alt="Lo-Fi Essentials cover art"
loading="lazy" class="h-[120px] w-full object-cover" />
<div class="p-4">
<h3 class="text-sm font-semibold text-primary-text mb-1 truncate">Lo-Fi Essentials</h3>
<p class="text-xs text-dim mb-2">Loop Pack · 32 files</p>
<span class="text-sm font-semibold text-primary">$19</span>
</div>
</article>
</a>
<!-- Card 3 -->
<a href="/products/trap-vault" class="group block bg-white/[0.04] rounded-xl overflow-hidden
hover:bg-white/[0.06] transition-colors focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-primary">
<article>
<div class="h-[120px] bg-gradient-to-br from-amber-400 to-red-500"
role="img" aria-label="Trap Vault cover art"></div>
<div class="p-4">
<h3 class="text-sm font-semibold text-primary-text mb-1 truncate">Trap Vault</h3>
<p class="text-xs text-dim mb-2">Sample Kit · 128 files</p>
<span class="text-sm font-semibold text-primary">$49</span>
</div>
</article>
</a>
</div>
<!-- Token mapping:
bg-white/[0.04] → --color-card
text-primary-text → --color-text (#EDEDED)
text-dim → --color-text-dim (#888888)
text-primary → --color-primary (#6BDDFF)
h-[120px] → --card-media-height
-->
Grid of feature cards with icon, title, and description. Each accent color maps to a system token.
A beautiful page to showcase all your products.
Get paid directly to your bank or PayPal.
Make your store match your brand identity.
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Primary accent -->
<article class="bg-white/[0.04] rounded-xl p-6">
<div class="w-10 h-10 rounded-[10px] flex items-center justify-center mb-3"
style="background: rgba(107,221,255,0.15);">
<svg class="text-primary" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
</svg>
</div>
<h3 class="text-sm font-semibold text-primary-text mb-1">Your Storefront</h3>
<p class="text-[13px] text-muted">A beautiful page to showcase all your products.</p>
</article>
<!-- Success accent -->
<article class="bg-white/[0.04] rounded-xl p-6">
<div class="w-10 h-10 rounded-[10px] flex items-center justify-center mb-3"
style="background: rgba(34,197,94,0.15);">
<svg class="text-success" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>
</svg>
</div>
<h3 class="text-sm font-semibold text-primary-text mb-1">Instant Payouts</h3>
<p class="text-[13px] text-muted">Get paid directly to your bank or PayPal.</p>
</article>
<!-- Purple accent -->
<article class="bg-white/[0.04] rounded-xl p-6">
<div class="w-10 h-10 rounded-[10px] flex items-center justify-center mb-3"
style="background: rgba(168,85,247,0.15);">
<svg class="text-purple" width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="M12 20h9M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
</div>
<h3 class="text-sm font-semibold text-primary-text mb-1">Custom Branding</h3>
<p class="text-[13px] text-muted">Make your store match your brand identity.</p>
</article>
</div>
<!-- Token mapping:
bg-white/[0.04] → --color-card
text-primary-text → --color-text (#EDEDED)
text-muted → --color-text-muted (#A1A1A1)
text-primary → --color-primary (#6BDDFF)
text-success → --color-success (#22C55E)
text-purple → --color-purple (#A855F7)
rounded-[10px] → --card-icon-radius
text-[13px] → --text-sm (13px), Tailwind text-sm = 14px ≠ system
-->
The entire card is clickable, not just the title. On hover, the background becomes slightly lighter; focus-visible shows a ring.
<!-- Wrap card in <a> for full-surface click -->
<a href="/products/midnight-drums"
class="group block bg-white/[0.04] rounded-xl overflow-hidden
hover:bg-white/[0.06] transition-colors
focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-primary">
<article>…</article>
</a>
<!-- Or via CSS if not using Tailwind: -->
<style>
.product-card {
background: var(--color-card);
border-radius: var(--radius-xl);
overflow: hidden;
transition: background 0.15s ease;
}
.product-card:hover {
background: rgba(255,255,255,0.06);
}
.product-card:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
</style>
Show skeleton placeholders while card data loads. When no items exist, use the Empty State pattern.
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
aria-busy="true" aria-label="Loading products">
<div class="bg-white/[0.04] rounded-xl overflow-hidden" aria-hidden="true">
<div class="skeleton h-[120px]"></div>
<div class="p-4 space-y-2">
<div class="skeleton skeleton-text w-3/4 h-3.5"></div>
<div class="skeleton skeleton-text w-1/2 h-3"></div>
<div class="skeleton skeleton-text w-1/4 h-3.5"></div>
</div>
</div>
<!-- Repeat × 3 -->
</div>
<!-- Empty: use the Empty State pattern when 0 items -->
Keyboard navigation and screen reader considerations for card grids.
<!-- 1. Clickable cards: wrap in <a> for keyboard access -->
<a href="/products/midnight-drums"
aria-label="Midnight Drums, Sample Kit, $29">
<article>…</article>
</a>
<!-- 2. Gradient placeholders need role="img" + aria-label -->
<div role="img" aria-label="Midnight Drums cover art"
class="h-[120px] bg-gradient-to-br …"></div>
<!-- 3. Real images: always provide alt text -->
<img src="/images/products/lofi-essentials.jpg"
alt="Lo-Fi Essentials cover art"
loading="lazy" class="h-[120px] w-full object-cover" />
<!-- 4. Feature card icons are decorative → aria-hidden -->
<svg aria-hidden="true">…</svg>
<!-- 5. Focus management: use focus-visible, not focus -->
<a class="… focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-primary">
<!-- 6. Skeleton loading: aria-busy on container -->
<div aria-busy="true" aria-label="Loading products">
<div aria-hidden="true">…skeleton…</div>
</div>
Structural breakdown of a product card and the grid container.
gap-4bg-white/[0.04], 12px radius, overflow hidden<img> or gradient with role="img"<h3>, truncated with truncate<p><span>All dimensions reference component tokens so sizes stay consistent across themes.
| Property | Token | Value |
|---|---|---|
| Layout | ||
| Columns (desktop ≥ 1024px) | lg:grid-cols-3 | 3 |
| Columns (tablet ≥ 768px) | md:grid-cols-2 | 2 |
| Columns (mobile) | grid-cols-1 | 1 |
| Gap | --space-4 |
16px |
| Card surface | ||
| Background | --color-card |
rgba(255,255,255, 0.04) |
| Border radius | --radius-xl |
12px (rounds to 16px) |
| Overflow | - | hidden (clips media) |
| Product Cards | ||
| Image height | --card-media-height |
120px |
| Content padding | --space-4 |
16px |
| Title | --text-base / --font-semibold |
14px, 600, --color-text |
| Subtitle | --text-xs |
12px, 400, --color-text-dim |
| Price | --text-base / --font-semibold |
14px, 600, --color-primary |
| Feature Cards | ||
| Content padding | --space-6 |
24px |
| Icon container | - | 40 × 40px, 10px radius |
| Icon container bg | - | accent color at 15% opacity |
| Title | --text-base / --font-semibold |
14px, 600, --color-text |
| Description | --text-sm |
13px, 400, --color-text-muted |
| Interactive | ||
| Hover bg | - | rgba(255,255,255, 0.06) |
| Focus ring | --color-primary |
2px solid, 2px offset |
text-sm = 14px; system --text-sm = 13px. Feature card descriptions use 13px (system token). Use text-[13px] in Tailwind to avoid ambiguity.
Keep card content consistent across the grid
Why: Same structure for all cards creates visual rhythm and makes scanning easy.Mix different card sizes and styles in one grid
Why: Inconsistent cards create visual chaos and break the scanning pattern.Make the entire card surface clickable
Why: Larger hit targets improve usability and mobile touch accuracy (Fitts's law).Only make the title a link inside a card
Why: Small click targets frustrate users, especially on touch devices.Truncate long titles to keep card heights consistent
Why: Usetruncate for single-line or line-clamp-2 for two-line limits. Consistent card heights maintain grid alignment.Use empty placeholder images in production
Why: Missing images look broken. Provide a branded fallback image or gradient placeholder.