Color Systems
Build perceptually uniform color systems in OKLCH — from primitives through semantic roles to dark-mode-safe themes that pass WCAG contrast.
Build color systems that are perceptually uniform, accessible, and themeable. Work in OKLCH — it matches how humans see lightness and color, unlike hex or HSL.
Why OKLCH
- Perceptual uniformity: equal lightness steps look equally different. L 0.9 to L 0.8 feels the same jump as L 0.5 to L 0.4.
- Polar coordinates: hue is a degree (0–360), chroma is saturation (0–0.4 typical), lightness is 0–1.
- No gamut clipping: unlike HSL, OKLCH colors that look the same brightness actually ARE the same brightness.
- Better contrast prediction: if two colors have ≥ 0.4 lightness difference, they almost certainly pass WCAG AA.
The Primitive Palette
Build a scale from 50 to 950 for each hue. Keep chroma consistent within a hue family.
/* Neutral scale (chroma 0.01–0.02, hue toward brand) */
--gray-50: oklch(0.98 0.005 240);
--gray-100: oklch(0.96 0.008 240);
--gray-200: oklch(0.92 0.01 240);
--gray-300: oklch(0.87 0.012 240);
--gray-400: oklch(0.71 0.015 240);
--gray-500: oklch(0.55 0.02 240);
--gray-600: oklch(0.45 0.02 240);
--gray-700: oklch(0.37 0.02 240);
--gray-800: oklch(0.27 0.015 240);
--gray-900: oklch(0.2 0.01 240);
--gray-950: oklch(0.14 0.008 240);
/* Brand scale (chroma 0.12–0.2, consistent hue) */
--blue-50: oklch(0.97 0.02 240);
--blue-100: oklch(0.93 0.04 240);
--blue-200: oklch(0.88 0.08 240);
--blue-300: oklch(0.8 0.12 240);
--blue-400: oklch(0.7 0.16 240);
--blue-500: oklch(0.55 0.2 240); /* primary */
--blue-600: oklch(0.48 0.2 240);
--blue-700: oklch(0.4 0.18 240);
--blue-800: oklch(0.32 0.14 240);
--blue-900: oklch(0.25 0.1 240);
Rules:
- Keep hue consistent within a family (±5° max).
- Increase chroma toward the middle of the scale (300–500), decrease at extremes.
- Neutral scales: chroma 0.005–0.02. Tint toward brand hue, not toward warmth-by-default.
The Semantic Layer
Map primitives to roles. This layer changes for dark mode; primitives stay.
/* Foreground hierarchy */
--fg-primary: var(--gray-900); /* headings, body text */
--fg-secondary: var(--gray-600); /* descriptions, labels */
--fg-tertiary: var(--gray-400); /* hints, placeholders */
--fg-muted: var(--gray-300); /* disabled, decorative */
/* Background elevation */
--bg-base: var(--gray-50); /* page background */
--bg-raised: var(--white); /* cards, modals */
--bg-sunken: var(--gray-100); /* inputs, code blocks */
--bg-overlay: oklch(0 0 0 / 0.5); /* modal backdrop */
/* Border progression */
--border-subtle: var(--gray-200); /* card borders, dividers */
--border-default: var(--gray-300); /* input borders */
--border-strong: var(--gray-400); /* focus rings */
/* Brand */
--color-primary: var(--blue-500);
--color-primary-hover: var(--blue-600);
--color-primary-active: var(--blue-700);
--color-primary-content: var(--white);
/* Semantic feedback */
--color-success: oklch(0.6 0.17 155);
--color-warning: oklch(0.75 0.16 85);
--color-error: oklch(0.6 0.2 25);
--color-info: oklch(0.6 0.15 240);
Dark Mode
Swap semantics, keep primitives:
[data-theme="dark"] {
--fg-primary: var(--gray-100);
--fg-secondary: var(--gray-400);
--fg-tertiary: var(--gray-500);
--bg-base: var(--gray-950);
--bg-raised: var(--gray-900);
--bg-sunken: var(--gray-800);
--border-subtle: var(--gray-800);
--border-default: var(--gray-700);
--color-primary: var(--blue-400);
--color-primary-hover: var(--blue-300);
--color-primary-content: var(--gray-950);
}
Rules:
- Dark mode primary is often 1–2 steps lighter than light mode (better contrast on dark bg).
- Semantic colors (success/warning/error) often need desaturation in dark mode to avoid vibrating.
color-scheme: dark;is mandatory for PageWeave dark themes.
Contrast Verification
The fast OKLCH contrast check: lightness difference ≥ 0.4 almost always passes WCAG AA (4.5:1).
For precise checks:
- Body text (normal): ≥ 4.5:1 against background.
- Large text (≥ 18px or bold ≥ 14px): ≥ 3:1.
- UI components: ≥ 3:1 against adjacent colors.
- Focus indicators: ≥ 3:1 against both background and adjacent colors.
Every *-content token must pass contrast against its parent:
--color-primary-contentagainst--color-primary.--fg-primaryagainst--bg-baseand--bg-raised.--fg-secondaryagainst--bg-base.
Color Usage Rules
- One accent color, used with intention. One primary color carries action. Secondary and accent are for rare emphasis.
- Semantic colors are functional. Success/warning/error appear in feedback contexts only — never decoration.
- Opacity modifiers over new colors.
text-base-content/60gives you a dimmed version that survives theming. Don't create--text-dimwhen opacity works. - Never use color alone to convey information. Pair with text, icon, or pattern.
- Test the full chain. primary → primary-hover → primary-active → primary-content. Each step should be visibly distinct.
Palette Anti-Patterns
- Purple-blue gradient: the most common AI color scheme. Avoid.
- Single-hue palettes: everything blue, or everything green. Use neutrals for structure, color for emphasis.
- Rainbow palettes: 6+ distinct hues in one interface. Stick to 1 brand hue + neutrals + semantic colors.
- Hardcoded hex in components:
bg-[#6366f1]doesn't survive theming. Usebg-primary. - Warm-by-default neutrals: tinting neutrals toward warmth "because the brand feels warm" is the monoculture move. Tint toward the brand's actual hue.
- Similar lightness for text and background: OKLCH L difference < 0.3 will fail contrast. Check before shipping.
PageWeave Workflow
- Define primitives and semantics in OKLCH.
- Pass bare variables to
update_theme(website, config: { css })— no[data-theme]wrapper. - Dark themes: include
color-scheme: dark;. - Test: screenshot a page with buttons, badges, inputs, tables, and an alert — all five exercise semantic colors.
- Verify contrast for every text/background pair.
- Check
.mdview: code blocks inherit theme colors.