Color Themes
When to Use
The palette is computed, not chosen: generating a full token set from one brand hue, deriving colours from an uploaded image, building a theme switcher, or producing harmonic schemes on demand. Anything where a function returns colours.
If the palette already exists and only needs applying, that is design-system. If the question is which hue suits a brand, that is a judgement call and belongs in design-principles. If the palette is to be derived from a specific painting, film grade, editor theme or other aesthetic lineage, the extraction protocol and the licensing catch for each source are in ../design-system/references/brand-extraction.md — bring the ramp back here to convert it into OKLCH and measure it.
Core Rules
- Generate in OKLCH, always. It is perceptually uniform: equal lightness steps look equal, and changing hue at fixed lightness does not change apparent brightness. HSL does neither —
hsl(60, 100%, 50%) yellow and hsl(240, 100%, 50%) blue claim the same lightness and differ by roughly 4:1 in fact. Every generated ramp built on HSL is wrong in a way its author cannot see.
- Clamp chroma to what the gamut holds. OKLCH describes colours sRGB cannot display. Past roughly C 0.37 at mid lightness the browser clips, and two different generated colours silently become the same rendered one. Clamp, or verify the result round-trips.
- Contrast is computed, not hoped for. A generated pair must be measured before it becomes a token. A theme generator that emits an untested surface/text pair has moved the accessibility failure from design time to runtime, where nobody is looking.
- Never average pixels to find a palette. The mean of a photograph is always the same muddy brown-grey. Cluster — median cut or k-means — and take cluster centroids.
- Theme switches are instant. A 300ms colour transition across a whole document reads as a rendering fault, not as polish. Switch the token values; do not animate them.
- Colour is never the only signal. A generated palette cannot know that red means error. Pair every colour-coded state with a label, an icon, or a shape.
auto is a third state, not the absence of a choice. Store "light" | "dark" | "auto". Storing only a resolved boolean means a user who chose "follow the system" is silently pinned the first time the system flips.
Patterns
- Derive the ramp from one anchor. Take the brand hue, then generate surface/elevated/border/muted/text by moving lightness on a fixed curve and reducing chroma as lightness approaches either end. Constant chroma across a ramp produces neon highlights and muddy shadows.
- Hue wheel for schemes. Complementary +180°, triadic ±120°, analogous ±30°, split-complementary ±150°. In OKLCH these stay equal-weight, which is exactly what the same rotation in HSL fails to do.
- Dark mode is not inverted light mode. Invert lightness, then lower chroma — saturated colour on a dark surface glows and vibrates. Roughly 0.75× is a sound starting point.
- Elevation by lightness, not by shadow, on dark themes. A raised surface is a lighter surface; drop shadows are close to invisible against near-black.
- Emit CSS custom properties, not inline styles. One
:root block per theme means the switch is one class change rather than a re-render.
Reference Index
| File |
Load when |
references/oklch-theme-engine.md |
Generating a token set or a harmonic scheme from an anchor colour |
references/image-palette-extraction.md |
Deriving colours from an uploaded image or a photograph |
references/accessibility-aware-schemes.md |
Checking a generated pair, APCA vs WCAG, prefers-contrast |
references/dark-light-auto-architecture.md |
Wiring the switcher, storage, SSR, and avoiding the flash of wrong theme |
../design-system/references/brand-extraction.md |
The anchor is to come from a painting, a film grade or an editor theme rather than a brand hue |
Anti-Patterns
- Raw hex anywhere in generated output. The whole point is a colour space you can reason about.
- A ramp built by changing HSL lightness only — the steps are visually uneven and the mid-tones go grey.
- Averaging an image's pixels for a "dominant colour".
- Transitioning
background-color on :root when the theme changes.
- Persisting a resolved
isDark boolean instead of the user's actual three-way choice.
- Generating a palette and shipping it without ever measuring a contrast value.
1---2name: color-themes3description: Algorithmic colour theme generation in OKLCH — harmonic schemes, palettes derived from an image, dark/light/auto architecture, and contrast checked before a token ships rather than after. Use when the palette is computed rather than chosen — generating a token set from one brand hue, deriving colours from an image, building a theme switcher, returning harmonic schemes from a function. Not for applying a palette that already exists (design-system).4---56# Color Themes78## When to Use9The palette is *computed*, not chosen: generating a full token set from one brand hue, deriving colours from an uploaded image, building a theme switcher, or producing harmonic schemes on demand. Anything where a function returns colours.1011If the palette already exists and only needs applying, that is `design-system`. If the question is which hue suits a brand, that is a judgement call and belongs in `design-principles`. If the palette is to be derived from a specific painting, film grade, editor theme or other aesthetic lineage, the extraction protocol and the licensing catch for each source are in `../design-system/references/brand-extraction.md` — bring the ramp back here to convert it into OKLCH and measure it.1213## Core Rules141. **Generate in OKLCH, always.** It is perceptually uniform: equal lightness steps look equal, and changing hue at fixed lightness does not change apparent brightness. HSL does neither — `hsl(60, 100%, 50%)` yellow and `hsl(240, 100%, 50%)` blue claim the same lightness and differ by roughly 4:1 in fact. Every generated ramp built on HSL is wrong in a way its author cannot see.152. **Clamp chroma to what the gamut holds.** OKLCH describes colours sRGB cannot display. Past roughly C 0.37 at mid lightness the browser clips, and two different generated colours silently become the same rendered one. Clamp, or verify the result round-trips.163. **Contrast is computed, not hoped for.** A generated pair must be *measured* before it becomes a token. A theme generator that emits an untested surface/text pair has moved the accessibility failure from design time to runtime, where nobody is looking.174. **Never average pixels to find a palette.** The mean of a photograph is always the same muddy brown-grey. Cluster — median cut or k-means — and take cluster centroids.185. **Theme switches are instant.** A 300ms colour transition across a whole document reads as a rendering fault, not as polish. Switch the token values; do not animate them.196. **Colour is never the only signal.** A generated palette cannot know that red means error. Pair every colour-coded state with a label, an icon, or a shape.207. **`auto` is a third state, not the absence of a choice.** Store `"light" | "dark" | "auto"`. Storing only a resolved boolean means a user who chose "follow the system" is silently pinned the first time the system flips.2122## Patterns23- **Derive the ramp from one anchor.** Take the brand hue, then generate surface/elevated/border/muted/text by moving lightness on a fixed curve and *reducing* chroma as lightness approaches either end. Constant chroma across a ramp produces neon highlights and muddy shadows.24- **Hue wheel for schemes.** Complementary +180°, triadic ±120°, analogous ±30°, split-complementary ±150°. In OKLCH these stay equal-weight, which is exactly what the same rotation in HSL fails to do.25- **Dark mode is not inverted light mode.** Invert lightness, then *lower* chroma — saturated colour on a dark surface glows and vibrates. Roughly 0.75× is a sound starting point.26- **Elevation by lightness, not by shadow, on dark themes.** A raised surface is a lighter surface; drop shadows are close to invisible against near-black.27- **Emit CSS custom properties, not inline styles.** One `:root` block per theme means the switch is one class change rather than a re-render.2829## Reference Index30| File | Load when |31|---|---|32| `references/oklch-theme-engine.md` | Generating a token set or a harmonic scheme from an anchor colour |33| `references/image-palette-extraction.md` | Deriving colours from an uploaded image or a photograph |34| `references/accessibility-aware-schemes.md` | Checking a generated pair, APCA vs WCAG, `prefers-contrast` |35| `references/dark-light-auto-architecture.md` | Wiring the switcher, storage, SSR, and avoiding the flash of wrong theme |36| `../design-system/references/brand-extraction.md` | The anchor is to come from a painting, a film grade or an editor theme rather than a brand hue |3738## Anti-Patterns39- Raw hex anywhere in generated output. The whole point is a colour space you can reason about.40- A ramp built by changing HSL lightness only — the steps are visually uneven and the mid-tones go grey.41- Averaging an image's pixels for a "dominant colour".42- Transitioning `background-color` on `:root` when the theme changes.43- Persisting a resolved `isDark` boolean instead of the user's actual three-way choice.44- Generating a palette and shipping it without ever measuring a contrast value.