---
name: frontend-design-system
description: >-
Enforces design token architecture, consistent scales, and theming patterns
when building or maintaining a design system. Use when defining CSS custom
properties, creating color palettes, setting up spacing or typography scales,
implementing dark/light themes, or establishing design consistency.
---
# Design System Tokens and Scales
Every visual decision — color, spacing, typography, motion, elevation — must
come from a design token, never a hardcoded value. Tokens are CSS custom
properties that serve as the single source of truth. When a token changes,
every component using it updates automatically. When a hardcoded value needs
changing, you have to find and fix every instance.
## Token Architecture
Use a two-tier system. This separation is what makes theming, refactoring,
and scaling possible:
1. **Primitive tokens** — raw values with descriptive names. These are the
palette. They never appear in component code.
2. **Semantic tokens** — contextual aliases that reference primitives. These
carry meaning. Components use only these.
```css
:root {
/* Primitives — the raw palette */
--gray-50: oklch(97% 0 0);
--gray-100: oklch(93% 0 0);
--gray-200: oklch(87% 0 0);
--gray-300: oklch(75% 0 0);
--gray-400: oklch(60% 0 0);
--gray-500: oklch(45% 0 0);
--gray-600: oklch(35% 0 0);
--gray-700: oklch(25% 0 0);
--gray-800: oklch(17% 0 0);
--gray-900: oklch(10% 0 0);
--gray-950: oklch(6% 0 0);
/* Semantics — what the palette means in context */
--color-bg: var(--gray-950);
--color-bg-surface: var(--gray-900);
--color-bg-elevated: var(--gray-800);
--color-text-primary: var(--gray-50);
--color-text-secondary: var(--gray-300);
--color-text-tertiary: var(--gray-400);
--color-border-default: var(--gray-800);
--color-border-strong: var(--gray-600);
}
```
**Why this matters**: swapping a theme (dark to light) means reassigning
semantic tokens to different primitives. Zero component code changes. Adding
a brand refresh means updating primitives. If components reference primitives
directly, both operations require touching every file.
## Color System
### Define palette in oklch
Use `oklch()` for all color definitions. Its perceptually uniform lightness
makes scales consistent — a lightness of 50% looks equally bright across all
hues. See the `frontend-css` skill for `oklch()` syntax details.
```css
:root {
/* Brand */
--blue-500: oklch(55% 0.2 250);
--blue-400: oklch(65% 0.18 250);
--blue-600: oklch(45% 0.2 250);
/* Semantic */
--color-accent: var(--blue-500);
--color-accent-hover: var(--blue-400);
--color-accent-active: var(--blue-600);
/* Status */
--color-success: oklch(65% 0.18 145);
--color-warning: oklch(75% 0.15 85);
--color-error: oklch(55% 0.2 25);
--color-info: oklch(60% 0.15 250);
}
```
### Dynamic variants with color-mix
Generate subtle backgrounds and overlays from existing tokens rather than
defining new primitives:
```css
:root {
--color-accent-subtle: color-mix(in oklch, var(--color-accent) 15%, var(--color-bg));
--color-error-subtle: color-mix(in oklch, var(--color-error) 15%, var(--color-bg));
}
```
### Surface / on-surface pairing
Every background token must have a corresponding text token that meets WCAG
AA contrast ratios (see the `frontend-accessibility` skill for thresholds):
| Background | Text | Min contrast | Use |
|------------|------|-------------|-----|
| `--color-bg` | `--color-text-primary` | 4.5:1 | Page background |
| `--color-bg-surface` | `--color-text-primary` | 4.5:1 | Cards, panels |
| `--color-bg-elevated` | `--color-text-primary` | 4.5:1 | Modals, dropdowns |
| `--color-accent` | `--color-on-accent` | 4.5:1 | Buttons, badges |
| `--color-error` | `--color-on-error` | 4.5:1 | Error states |
When defining a new background token, always define its on-surface pair and
verify the contrast ratio before using it.
## Spacing Scale
Use a base-4 scale. Every spacing value is a multiple of 4px. This creates
a visual rhythm — elements feel related when they share consistent spacing.
```css
:root {
--space-xs: 0.25rem; /* 4px */
--space-sm: 0.5rem; /* 8px */
--space-md: 0.75rem; /* 12px */
--space-lg: 1rem; /* 16px */
--space-xl: 1.5rem; /* 24px */
--space-2xl: 2rem; /* 32px */
--space-3xl: 3rem; /* 48px */
--space-4xl: 4rem; /* 64px */
--space-5xl: 6rem; /* 96px */
}
```
Use spacing tokens for all padding, margin, and gap values. If you find
yourself wanting a value not in the scale (e.g., 13px), that's a signal
the design needs adjustment, not the scale.
## Typography Scale
Use a modular scale based on a consistent ratio.
### Major Third (1.25) — default
Works well for application interfaces where content density matters:
```css
:root {
--text-xs: 0.64rem; /* ~10px */
--text-sm: 0.8rem; /* ~13px */
--text-base: 1rem; /* 16px */
--text-md: 1.25rem; /* 20px */
--text-lg: 1.563rem; /* ~25px */
--text-xl: 1.953rem; /* ~31px */
--text-2xl: 2.441rem; /* ~39px */
--text-3xl: 3.052rem; /* ~49px */
--text-4xl: 3.815rem; /* ~61px */
}
```
### Supporting tokens
```css
:root {
--font-normal: 400;
--font-medium: 500;
--font-semibold: 600;
--font-bold: 700;
--leading-tight: 1.2; /* headings */
--leading-normal: 1.5; /* body text */
--leading-relaxed: 1.75; /* large body, captions */
--tracking-tight: -0.02em; /* large headings */
--tracking-normal: 0; /* body text */
--tracking-wide: 0.05em; /* small uppercase labels */
--tracking-wider: 0.1em; /* micro labels, overlines */
}
```
Tight tracking on large headings, normal on body, wide on small uppercase
labels — this is not aesthetic preference, it's how letterform optics work
at different sizes.
## Motion Tokens
```css
:root {
--duration-fast: 100ms;
--duration-base: 200ms;
--duration-slow: 300ms;
--duration-slower: 500ms;
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--transition-fast: var(--duration-fast) var(--ease-default);
--transition-base: var(--duration-base) var(--ease-default);
--transition-slow: var(--duration-slow) var(--ease-default);
}
```
### Respect reduced motion
```css
@media (prefers-reduced-motion: reduce) {
:root {
--duration-fast: 0ms;
--duration-base: 0ms;
--duration-slow: 0ms;
--duration-slower: 0ms;
}
}
```
By zeroing duration tokens, every transition and animation using them stops
automatically. No per-component overrides needed. This is why all motion
must go through tokens — direct `transition: 200ms` bypasses the user's
accessibility preference.
## Elevation and Shadow Scale
```css
:root {
--shadow-xs: 0 1px 2px oklch(0% 0 0 / 0.05);
--shadow-sm: 0 1px 3px oklch(0% 0 0 / 0.1),
0 1px 2px oklch(0% 0 0 / 0.06);
--shadow-md: 0 4px 6px oklch(0% 0 0 / 0.1),
0 2px 4px oklch(0% 0 0 / 0.06);
--shadow-lg: 0 10px 15px oklch(0% 0 0 / 0.1),
0 4px 6px oklch(0% 0 0 / 0.05);
--shadow-xl: 0 20px 25px oklch(0% 0 0 / 0.1),
0 8px 10px oklch(0% 0 0 / 0.04);
}
```
Shadow progression: `xs` for subtle depth (inputs, small cards), `sm`-`md`
for standard cards and panels, `lg`-`xl` for elevated overlays (modals,
dropdowns). Each step increases the blur radius and offset to simulate
increasing physical distance from the surface.
## Border Radius Scale
```css
:root {
--radius-sm: 0.25rem; /* 4px — inputs, small elements */
--radius-md: 0.5rem; /* 8px — cards, buttons */
--radius-lg: 0.75rem; /* 12px — larger cards, panels */
--radius-xl: 1rem; /* 16px — modals, sections */
--radius-full: 9999px; /* pill shapes, avatars */
}
```
## Z-Index Scale
Define named layers. Arbitrary z-index values are how stacking context bugs
happen — they accumulate silently until something breaks.
```css
:root {
--z-base: 0;
--z-dropdown: 100;
--z-sticky: 200;
--z-overlay: 300;
--z-modal: 400;
--z-popover: 500;
--z-toast: 600;
--z-tooltip: 700;
}
```
The 100-increment gap between layers allows inserting intermediate values
if needed without renumbering everything.
## Dark / Light Theming
Control theme via `data-theme` attribute on `<html>`. Override semantic
tokens only — never duplicate component styles for a theme.
```css
[data-theme="light"] {
--color-bg: var(--gray-50);
--color-bg-surface: white;
--color-bg-elevated: var(--gray-100);
--color-text-primary: var(--gray-900);
--color-text-secondary: var(--gray-600);
--color-text-tertiary: var(--gray-500);
--color-border-default: var(--gray-200);
--color-border-strong: var(--gray-400);
}
```
Respect system preference as the default:
```css
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--color-bg: var(--gray-50);
/* Same overrides as [data-theme="light"] */
}
}
```
### Theme toggle
```javascript
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
const saved = localStorage.getItem('theme');
if (saved) setTheme(saved);
```
Apply the saved theme before the page renders (in a blocking `<script>` in
`<head>`) to prevent a flash of the wrong theme on page load.
## Naming Conventions
| Convention | Example | Use for |
|------------|---------|---------|
| Category prefix | `--color-`, `--space-`, `--text-` | Grouping by purpose |
| Scale suffix | `--gray-500`, `--space-xl` | Ordered values |
| State suffix | `--color-accent-hover` | Interactive states |
| Semantic names | `--color-bg-surface` | Contextual meaning |
**Name tokens after their role, never their value.** `--color-accent` not
`--blue`. `--space-lg` not `--24px`. Value-based names break when the value
changes — and it will.
## Adding New Tokens
When you need a new token:
1. **Check the existing scale first.** Can an existing token work? Often
the need for a new token means the design is slightly off-grid, and
adjusting the design is better than adding a one-off token.
2. **Determine the tier.** Is this a new primitive (new shade, new hue) or
a new semantic (new context for an existing primitive)?
3. **Follow the naming convention.** Prefix with category, suffix with
scale position or semantic role.
4. **Place it with its peers.** Add it near related tokens, not at the
bottom of the file.
5. **If adding a background token, add its on-surface pair.** Verify
contrast meets WCAG AA (4.5:1 for normal text).
## Anti-Patterns
**Never do these:**
- Hardcode color values in component styles — use semantic tokens
- Use `rgb()` or `hsl()` for new palettes — use `oklch()` for perceptual
uniformity (see `frontend-css` skill)
- Create one-off spacing values like `13px` — use the scale; adjust the
design if nothing fits
- Name tokens after appearance (`--big-text`) — name after role
(`--text-xl`)
- Reference primitive tokens in component code — always go through semantic
tokens
- Set `z-index: 9999` — use the z-index scale
- Define shadows with hex colors — use `oklch()` with alpha for consistency
- Skip `prefers-reduced-motion` — motion tokens handle it automatically
when all animations use duration tokens
- Create theme variations by duplicating component styles — override
semantic tokens only
- Add a new spacing value "between" existing scale steps (e.g., 14px) —
the scale exists to constrain choices; embrace the constraint