What is a color system?
A color system defines roles, scales (shade sets), and usage rules — to make UI consistent, accessible, and easily themable.
Core roles
- Brand / Accent – CTAs, links, active states.
- Neutrals – backgrounds, cards, text, borders.
- Semantic –
ok,warn,info,danger. - States – hover, active, focus, disabled.
Use OKLCH for predictable contrast
oklch(L C H) lets you control lightness (L) and contrast more clearly than HSL. Lock the brand hue via H, then adjust only L to create shades.
:root{
/* Brand hue (change once for theming) */
--brand-h: 260;
/* Accent scale */
--accent-50 : oklch(0.97 0.02 var(--brand-h));
--accent-100: oklch(0.92 0.04 var(--brand-h));
--accent-300: oklch(0.78 0.10 var(--brand-h));
--accent-500: oklch(0.64 0.14 var(--brand-h)); /* primary */
--accent-600: oklch(0.58 0.14 var(--brand-h));
--accent-700: oklch(0.52 0.12 var(--brand-h));
/* Neutrals (ink & surfaces) – only L changes */
--ink-900 : oklch(0.22 0 0);
--ink-700 : oklch(0.35 0 0);
--ink-500 : oklch(0.55 0 0);
--surface : oklch(0.98 0 0);
--card : oklch(0.99 0 0);
--border : oklch(0.90 0 0);
/* Semantic */
--ok : oklch(0.62 0.12 150);
--warn : oklch(0.78 0.13 80);
--danger : oklch(0.62 0.15 25);
}
Role-based tokens
UI never uses “raw” colors – only role tokens (makes theming easy).
:root{
--bg: var(--surface);
--text: var(--ink-900);
--muted: var(--ink-700);
--link: var(--accent-500);
--link-hover: var(--accent-600);
--focus-ring: color-mix(in oklab, var(--accent-500) 35%, transparent);
}
a{ color: var(--link); }
a:hover{ color: var(--link-hover); }
:focus-visible{ outline: 3px solid var(--focus-ring); outline-offset: 2px; }
States: hover / active / disabled
Create derivatives with color-mix() — no need to keep dozens of variables.
.btn{
--btn-bg: var(--accent-500);
--btn-fg: white;
background: var(--btn-bg);
color: var(--btn-fg);
border: 1px solid color-mix(in oklab, var(--btn-bg) 70%, black);
}
.btn:hover{
background: color-mix(in oklab, var(--btn-bg) 88%, black);
}
.btn:active{
background: color-mix(in oklab, var(--btn-bg) 78%, black);
}
.btn[disabled]{
background: color-mix(in oklab, var(--btn-bg) 25%, var(--surface));
color: color-mix(in oklab, var(--btn-fg) 60%, var(--muted));
opacity: .7; pointer-events: none;
}
Elevation: surfaces, cards, overlays
As layers rise, increase contrast and add soft shadows.
.surface-1{ background: var(--surface); }
.card{
background: var(--card);
border: 1px solid var(--border);
box-shadow: 0 1px 2px rgba(0,0,0,.05), 0 10px 24px rgba(0,0,0,.06);
}
.overlay{
background: color-mix(in oklab, black 40%, transparent);
backdrop-filter: blur(2px);
}
Dark mode (single toggle)
For the same H, raise L for text and lower it for backgrounds. Invert neutrals.
:root[data-theme="slate"]{
--surface: oklch(0.14 0 0);
--card: oklch(0.18 0 0);
--border: oklch(0.28 0 0);
--text: oklch(0.92 0 0);
--muted: oklch(0.78 0 0);
/* Accent – slightly lighter for contrast on dark */
--accent-500: oklch(0.72 0.14 var(--brand-h));
--link: var(--accent-500);
--link-hover: oklch(0.80 0.12 var(--brand-h));
}
Contrast rules (quick)
- Body text ≥ 4.5:1, large headings ≥ 3:1.
- On colored backgrounds, use “ink on brand” pairs:
on-accent,on-danger, etc.
:root{
--on-accent: white; /* if L < .6 → use white, otherwise ink-900 */
}
.badge{ background: var(--accent-100); color: var(--ink-900); }
.cta{ background: var(--accent-500); color: var(--on-accent); }
Theming: change only the base
For each theme, change --brand-h and, if needed, neutral L levels.
:root[data-theme="ocean"]{ --brand-h: 230; } /* blue */
:root[data-theme="forest"]{ --brand-h: 150; } /* green */
:root[data-theme="rose"]{ --brand-h: 350; } /* pink */
Quick checklist
6–8 step fluid scale with
clamp()
Base text 16–18px, body LH 1.45–1.6
Measure: 60–75ch
Contrast ≥ WCAG AA
Preload fonts + font-display: swap + metrics override
Semantic tokens: body, lead, kicker, caption, code