Patterns — Design Tokens & Theming
Contents
- Starter token sheet (primitives)
- Semantic tokens, light + dark
- Component tier example
- Theme toggle (no-flash)
- Contrast quick-check
- Gotchas
Starter token sheet (primitives)
css
:root {
/* Neutral ramp (5 steps is enough to start) */
--gray-50: #f8fafc;
--gray-200: #e2e8f0;
--gray-500: #64748b;
--gray-800: #1e293b;
--gray-950: #0b1220;
/* Accent ramp */
--blue-400: #60a5fa;
--blue-600: #2563eb;
--blue-700: #1d4ed8;
/* Status */
--green-600: #16a34a;
--amber-600: #d97706;
--red-600: #dc2626;
/* Spacing — base-4 scale */
--space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem;
--space-4: 1rem; --space-6: 1.5rem; --space-8: 2rem;
--space-12: 3rem; --space-16: 4rem;
/* Radius & shadows — 3 levels each */
--radius-sm: 0.25rem; --radius-md: 0.5rem; --radius-full: 9999px;
--shadow-1: 0 1px 2px rgb(0 0 0 / 0.06);
--shadow-2: 0 4px 8px rgb(0 0 0 / 0.10);
--shadow-3: 0 12px 24px rgb(0 0 0 / 0.14);
color-scheme: light dark;
}Semantic tokens, light + dark
css
/* Light (default) */
:root, [data-theme="light"] {
--color-bg-page: var(--gray-50);
--color-bg-surface: #ffffff;
--color-text: var(--gray-800); /* on bg-page: 12.6:1 ✓ */
--color-text-muted: var(--gray-500); /* on surface: 4.8:1 ✓ */
--color-border: var(--gray-200);
--color-accent: var(--blue-600); /* as text on surface: 5.2:1 ✓ */
--color-accent-hover: var(--blue-700);
--color-danger: var(--red-600);
}
/* Dark via system preference (only when no explicit choice) */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--color-bg-page: var(--gray-950);
--color-bg-surface: var(--gray-800);
--color-text: var(--gray-50);
--color-text-muted: #94a3b8; /* on gray-800: 5.9:1 ✓ */
--color-border: #334155;
--color-accent: var(--blue-400); /* lighter accent for dark bg: 6.6:1 ✓ */
--color-accent-hover: #93c5fd;
}
}
/* Dark via explicit user choice — identical block, attribute-scoped */
[data-theme="dark"] {
--color-bg-page: var(--gray-950);
--color-bg-surface: var(--gray-800);
--color-text: var(--gray-50);
--color-text-muted: #94a3b8;
--color-border: #334155;
--color-accent: var(--blue-400);
--color-accent-hover: #93c5fd;
}Component tier example
css
.button-primary {
background: var(--color-accent);
color: var(--color-bg-surface);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
box-shadow: var(--shadow-1);
}
.button-primary:hover { background: var(--color-accent-hover); }
.card {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-6);
}Theme toggle (no-flash)
html
<!-- In <head>, BEFORE any stylesheet, so first paint is themed -->
<script>
const t = localStorage.getItem("theme"); // "light" | "dark" | null
if (t) document.documentElement.dataset.theme = t; // absent = follow system
</script>js
function setTheme(next) { // next: "light" | "dark" | "system"
if (next === "system") {
delete document.documentElement.dataset.theme;
localStorage.removeItem("theme");
} else {
document.documentElement.dataset.theme = next;
localStorage.setItem("theme", next);
}
}Contrast quick-check
Compute ratio = (L1 + 0.05) / (L2 + 0.05) with relative luminance, or verify in devtools (element → color picker shows the ratio). Thresholds to enforce in the token sheet:
| Pair | Minimum |
|---|---|
| Body/label text on its background | 4.5:1 (AA) |
| Large text ≥24px (or 18.7px bold) | 3:1 |
| UI component borders, icons, focus rings | 3:1 |
Gotchas
prefers-color-schemeblock must exclude explicit choices — scope it with:root:not([data-theme])or a user who picked "light" gets system-dark anyway.- Forgetting
color-scheme: light darkleaves scrollbars, form controls, and default UA styles stuck in light mode. - Muted text is the #1 AA failure — grays around
#999fail on white (2.8:1). Nothing lighter than#767676for body-size text on white. - Same accent in both themes usually fails one of them — accents need a per-theme value (darker for light bg, lighter for dark bg).
rgb(0 0 0 / 0.5)-style translucent text has no fixed contrast ratio (depends on what's behind) — use opaque token colors for text.- Tokens in shadows: define whole shadows as tokens, not just colors — dark themes often need different alpha, not different hue.