Theming
Purpose
Build themes that remain readable and coherent when switched. Dark mode is not an inversion; a palette that works on white will not work on black without being redesigned, and inverting it produces the vibrating, exhausting interfaces that dark mode is criticized for.
When to Use
- Adding dark mode to an interface.
- Building a multi-theme or white-label system.
- Generating a palette from a brand colour.
- Fixing a theme where contrast breaks in one mode.
Capabilities
- Semantic token architecture that supports theme switching.
- Dark-mode design (as opposed to inversion).
- Palette generation with perceptually uniform colour spaces.
- Contrast verification across every theme.
- Respecting system preference and user override.
Inputs
- The base palette or the seed colour.
- The themes required: light, dark, high-contrast, per-brand.
- Accessibility requirements.
Outputs
- Semantic tokens that resolve differently per theme.
- Every theme passing contrast requirements.
- A switch that respects the system preference and remembers an override.
Workflow
- Build on semantic tokens — Components reference
--color-surface, never --grey-900. Only the semantic layer changes between themes; components do not change at all.
- Design the dark theme, do not invert it — Pure black is harsh and causes halation with white text. Use a very dark grey. Desaturate the accents: a colour that is vivid on white is glaring on dark.
- Preserve the contrast relationships — Verify every text-on-surface pairing in every theme. A ratio that passes in light frequently fails in dark.
- Work in a perceptual colour space — OKLCH lightness is perceptually uniform; HSL's is not. Generating a scale in HSL produces steps that look wrong even though the numbers are even.
- Respect the system, allow an override — Default to
prefers-color-scheme, and store an explicit user choice.
- Handle elevation — In light mode, elevation is a shadow. In dark mode, shadows are invisible; elevation is a lighter surface.
Best Practices
- Pure black (
#000) with pure white text causes halation — the text appears to vibrate. Every well-designed dark theme uses a dark grey around #121212 to #1a1a1a.
- A saturated brand colour that looks correct on white will glow uncomfortably on a dark background. Reduce its saturation and raise its lightness for the dark theme.
- Elevation in dark mode is conveyed by making a surface lighter, not by adding a shadow. A shadow on a dark background is invisible.
- Test every theme against the contrast requirements. This is the single most common theming failure, and it is entirely mechanical to check.
- A theme toggle that does not persist is a bug that users notice on every page load.
- Do not animate the theme transition on colours. It looks impressive once and is irritating thereafter.
Examples
Semantic tokens, with the dark theme designed rather than inverted:
:root {
/* Light: surfaces are near-white; elevation is a shadow. */
--color-bg: oklch(99% 0 0);
--color-surface: oklch(100% 0 0);
--color-surface-raised: oklch(100% 0 0);
--shadow-raised: 0 1px 3px oklch(0% 0 0 / 0.12);
--color-text: oklch(20% 0.01 250);
--color-text-muted: oklch(50% 0.01 250);
--color-border: oklch(90% 0.005 250);
--color-action: oklch(55% 0.20 255); /* the brand blue */
--color-action-text: oklch(100% 0 0);
}
[data-theme="dark"] {
/* Not an inversion. #121212-equivalent, not black: pure black causes
halation with white text. */
--color-bg: oklch(15% 0.005 250);
--color-surface: oklch(19% 0.005 250);
/* Elevation in dark mode is a LIGHTER surface. A shadow would be invisible. */
--color-surface-raised: oklch(24% 0.006 250);
--shadow-raised: none;
--color-text: oklch(93% 0.005 250); /* not pure white */
--color-text-muted: oklch(65% 0.008 250);
--color-border: oklch(30% 0.008 250);
/* The brand blue, desaturated and lightened. The light-mode value
(55% 0.20) glows uncomfortably against a dark surface. */
--color-action: oklch(68% 0.14 255);
--color-action-text: oklch(15% 0.005 250);
}
Verifying contrast mechanically, in every theme:
const PAIRS = [
["--color-text", "--color-surface", 4.5], // body text: AA
["--color-text-muted", "--color-surface", 4.5],
["--color-action", "--color-surface", 3.0], // UI component boundary
["--color-action-text","--color-action", 4.5], // text on the button
] as const;
for (const theme of ["light", "dark", "high-contrast"] as const) {
for (const [fg, bg, required] of PAIRS) {
const ratio = contrastRatio(resolve(fg, theme), resolve(bg, theme));
if (ratio < required) {
throw new Error(
`Theme "${theme}": ${fg} on ${bg} is ${ratio.toFixed(2)}:1, ` +
`below the required ${required}:1.`
);
}
}
}
Notes
- OKLCH is worth adopting specifically because its lightness channel is perceptually uniform. Generating a ten-step scale by evenly spacing HSL lightness produces steps that appear badly uneven; the same in OKLCH does not.
- The "elevation is a lighter surface" rule is the single detail that most distinguishes a well-designed dark theme from an inverted light one.
- Run the contrast check in CI. Themes drift as colours are adjusted, and a failing contrast ratio is silent until a user cannot read the interface.
1---2name: theming3description: Use when building a theme or theming system for an interface. Covers token architecture, dark mode, contrast preservation across themes, and generating a coherent palette from a seed colour.4---56# Theming78## Purpose910Build themes that remain readable and coherent when switched. Dark mode is not an inversion; a palette that works on white will not work on black without being redesigned, and inverting it produces the vibrating, exhausting interfaces that dark mode is criticized for.1112## When to Use1314- Adding dark mode to an interface.15- Building a multi-theme or white-label system.16- Generating a palette from a brand colour.17- Fixing a theme where contrast breaks in one mode.1819## Capabilities2021- Semantic token architecture that supports theme switching.22- Dark-mode design (as opposed to inversion).23- Palette generation with perceptually uniform colour spaces.24- Contrast verification across every theme.25- Respecting system preference and user override.2627## Inputs2829- The base palette or the seed colour.30- The themes required: light, dark, high-contrast, per-brand.31- Accessibility requirements.3233## Outputs3435- Semantic tokens that resolve differently per theme.36- Every theme passing contrast requirements.37- A switch that respects the system preference and remembers an override.3839## Workflow40411. **Build on semantic tokens** — Components reference `--color-surface`, never `--grey-900`. Only the semantic layer changes between themes; components do not change at all.422. **Design the dark theme, do not invert it** — Pure black is harsh and causes halation with white text. Use a very dark grey. Desaturate the accents: a colour that is vivid on white is glaring on dark.433. **Preserve the contrast relationships** — Verify every text-on-surface pairing in every theme. A ratio that passes in light frequently fails in dark.444. **Work in a perceptual colour space** — OKLCH lightness is perceptually uniform; HSL's is not. Generating a scale in HSL produces steps that look wrong even though the numbers are even.455. **Respect the system, allow an override** — Default to `prefers-color-scheme`, and store an explicit user choice.466. **Handle elevation** — In light mode, elevation is a shadow. In dark mode, shadows are invisible; elevation is a lighter surface.4748## Best Practices4950- Pure black (`#000`) with pure white text causes halation — the text appears to vibrate. Every well-designed dark theme uses a dark grey around `#121212` to `#1a1a1a`.51- A saturated brand colour that looks correct on white will glow uncomfortably on a dark background. Reduce its saturation and raise its lightness for the dark theme.52- Elevation in dark mode is conveyed by making a surface *lighter*, not by adding a shadow. A shadow on a dark background is invisible.53- Test every theme against the contrast requirements. This is the single most common theming failure, and it is entirely mechanical to check.54- A theme toggle that does not persist is a bug that users notice on every page load.55- Do not animate the theme transition on colours. It looks impressive once and is irritating thereafter.5657## Examples5859**Semantic tokens, with the dark theme designed rather than inverted:**6061```css62:root {63 /* Light: surfaces are near-white; elevation is a shadow. */64 --color-bg: oklch(99% 0 0);65 --color-surface: oklch(100% 0 0);66 --color-surface-raised: oklch(100% 0 0);67 --shadow-raised: 0 1px 3px oklch(0% 0 0 / 0.12);6869 --color-text: oklch(20% 0.01 250);70 --color-text-muted: oklch(50% 0.01 250);71 --color-border: oklch(90% 0.005 250);7273 --color-action: oklch(55% 0.20 255); /* the brand blue */74 --color-action-text: oklch(100% 0 0);75}7677[data-theme="dark"] {78 /* Not an inversion. #121212-equivalent, not black: pure black causes79 halation with white text. */80 --color-bg: oklch(15% 0.005 250);81 --color-surface: oklch(19% 0.005 250);8283 /* Elevation in dark mode is a LIGHTER surface. A shadow would be invisible. */84 --color-surface-raised: oklch(24% 0.006 250);85 --shadow-raised: none;8687 --color-text: oklch(93% 0.005 250); /* not pure white */88 --color-text-muted: oklch(65% 0.008 250);89 --color-border: oklch(30% 0.008 250);9091 /* The brand blue, desaturated and lightened. The light-mode value92 (55% 0.20) glows uncomfortably against a dark surface. */93 --color-action: oklch(68% 0.14 255);94 --color-action-text: oklch(15% 0.005 250);95}96```9798**Verifying contrast mechanically, in every theme:**99100```typescript101const PAIRS = [102 ["--color-text", "--color-surface", 4.5], // body text: AA103 ["--color-text-muted", "--color-surface", 4.5],104 ["--color-action", "--color-surface", 3.0], // UI component boundary105 ["--color-action-text","--color-action", 4.5], // text on the button106] as const;107108for (const theme of ["light", "dark", "high-contrast"] as const) {109 for (const [fg, bg, required] of PAIRS) {110 const ratio = contrastRatio(resolve(fg, theme), resolve(bg, theme));111 if (ratio < required) {112 throw new Error(113 `Theme "${theme}": ${fg} on ${bg} is ${ratio.toFixed(2)}:1, ` +114 `below the required ${required}:1.`115 );116 }117 }118}119```120121## Notes122123- OKLCH is worth adopting specifically because its lightness channel is perceptually uniform. Generating a ten-step scale by evenly spacing HSL lightness produces steps that appear badly uneven; the same in OKLCH does not.124- The "elevation is a lighter surface" rule is the single detail that most distinguishes a well-designed dark theme from an inverted light one.125- Run the contrast check in CI. Themes drift as colours are adjusted, and a failing contrast ratio is silent until a user cannot read the interface.