Home / Snippets / Color & Theming /
Dark mode color pair
Define a foreground/background --fg/--bg pair with @media (prefers-color-scheme) so both modes always have matching, contrast-safe oklch values.
Quick implementation
:root {
--fg: oklch(0.18 0.02 260);
--bg: oklch(0.97 0.01 260);
}
@media (prefers-color-scheme: dark) {
:root {
--fg: oklch(0.93 0.01 260);
--bg: oklch(0.13 0.02 260);
}
}
.surface {
background: var(--bg);
color: var(--fg);
}
Prompt this to your LLM
Includes role, oklch constraints, framework variants, and edge cases to handle.
You are a senior frontend engineer defining a color system for a web UI
that must support both light and dark modes without JavaScript.
Goal: Create a CSS custom property pattern for a foreground/background
color pair — --fg and --bg — that automatically switches between light and
dark mode values using @media (prefers-color-scheme). All colors must use
oklch() notation for perceptual uniformity.
Technical constraints:
- Define base (light) values on :root using oklch() with L above 0.90
for --bg and below 0.20 for --fg so contrast exceeds WCAG AA (4.5:1).
- Override both properties inside @media (prefers-color-scheme: dark) on
:root — invert the lightness relationship: dark --bg ≤ 0.15 L,
light --fg ≥ 0.88 L.
- Use oklch() throughout — no hex, hsl, or rgba values anywhere.
- Keep chroma low (≤ 0.03) for neutral grays, or use a consistent hue
channel so the pair feels tonally related across modes.
- Apply the pair via background: var(--bg); color: var(--fg); on the
surface class or body — never hard-code a color value outside :root.
Framework variant (pick one):
A) Pure CSS — :root custom properties + @media block, applied to body or
a .surface utility class.
B) CSS Modules (React/Next.js) — export the :root block from a global
stylesheet, consume var(--fg) / var(--bg) inside component .module.css
files without re-declaring media queries per component.
Edge cases to handle:
- System appearance can change while the page is open (user toggles OS
setting) — because var() is live, the values update instantly with no
JS required.
- If a component needs to force dark appearance regardless of system
preference (e.g. a code block or hero image), wrap it in a
color-scheme: dark declaration with explicit oklch overrides rather
than relying on the media query.
- High-contrast mode (prefers-contrast: more) may need L values pushed
further apart — add a separate @media block that tightens both ends
toward pure black/white.
- Avoid defining --fg and --bg as individual L, C, H channels (oklch()
with separate custom properties) unless you intend to animate them —
the three-argument split adds complexity without benefit for static pairs.
Return CSS only (or CSS Modules structure if variant B is chosen).
Why paired colors matter
A color pair is a commitment: when you define --fg and --bg together in a single media query block, you guarantee that they are always evaluated as a set. Any surface that uses both properties will always have a foreground chosen specifically to be legible against its background in the current mode. This is contrast-safe by construction — you cannot accidentally mix a light-mode foreground with a dark-mode background because both variables live in the same :root declaration and update atomically.
The alternative — scattering color values throughout component stylesheets — means every component must independently manage its own dark-mode overrides. Forget one, and you get invisible text. The paired custom property pattern makes correct contrast the default and incorrect contrast the exceptional case that requires deliberate effort to produce.
The oklch() advantage
Traditional dark-mode implementations used HSL values, which have an uneven perceptual lightness distribution. A color at hsl(220, 10%, 13%) and another at hsl(220, 10%, 93%) feel roughly like an 80-point jump in lightness — but different hue channels at the same HSL lightness look dramatically different in perceived brightness. For example, yellow at hsl(60, 80%, 50%) looks much lighter than blue at hsl(240, 80%, 50%) even though both share L=50%.
oklch() fixes this. The L channel in oklch is perceptually uniform: oklch(0.13 0.02 260) and oklch(0.93 0.01 260) have an 0.80 lightness separation that you can trust visually regardless of hue. This means WCAG contrast calculations become more predictable — the same L-channel gap at hue 260 (blue-purple) produces approximately the same contrast as the same gap at hue 30 (orange). You can set your dark-mode --bg L to 0.13 and your --fg L to 0.93, and the contrast ratio will exceed WCAG AA (4.5:1) for any chroma values in the normal range.
The low chroma values used here (0.01–0.02) produce near-neutral tones with just enough hue character to feel intentional rather than flat. Increasing chroma introduces more color at the cost of reduced contrast headroom — at higher chroma values you must verify with a contrast checker since the perceptual uniformity guarantee weakens at the extremes.
WCAG contrast and the pair pattern
WCAG 2.1 Success Criterion 1.4.3 requires a minimum contrast ratio of 4.5:1 for normal text and 3:1 for large text (AA level). WCAG 2.1 SC 1.4.6 tightens this to 7:1 and 4.5:1 for AAA. The contrast ratio between oklch(0.13 0.02 260) and oklch(0.93 0.01 260) exceeds 13:1 — well above AAA — because the lightness separation of 0.80 in the oklch space maps to a relative luminance ratio well beyond the threshold.
When you define the pair at the :root level, contrast compliance becomes a system property rather than a per-component concern. Add a new component that uses color: var(--fg); background: var(--bg) and it inherits compliance automatically. This is the architectural argument for the pattern: contrast is solved once, at the token layer, not repeatedly at the component layer.
One caveat: the pair only guarantees contrast between --fg text and --bg surfaces. Semi-transparent overlays, gradients, or accent colors layered on top of --bg need their own contrast verification. The pair is a foundation, not a complete color system.