Theming & Dark Mode Implementation
Master the architecture behind first-class theming: semantic token tiers, luminance-aware dark surfaces, and system-preference integration that scales across every platform.
8 min read
The full lesson
Theming is not something you bolt on at the end of a design system build. It is a structural decision that shapes every token, every component, and every handoff workflow from day one.
Get it wrong and you end up hard-coding colors in components, shipping a dark mode that is just a hex-inverted light mode, and fighting inconsistency across platforms for years. Get it right and adding a brand theme or a high-contrast variant becomes a configuration change — not a refactor.
Why Theming Deserves First-Class Status
Many teams treat themes as a skin layer: swap some colors and call it done. That shortcut causes real problems.
Drop shadows disappear on dark backgrounds because they rely on a lighter surface beneath them. Body text becomes pure white on pure black (#FFF on #000), which is technically high-contrast but causes halation — a glowing, blurring effect — and eye fatigue in low-light environments. Elevation systems break entirely because they depended on shadows, not luminance.
A theme is a full re-expression of your token values. It is not a cosmetic overlay. In dark mode, elevation is expressed through lighter surface tones, not shadows. Interactive states need different opacity values. Even motion and border strategies can shift between themes. Treating dark mode as a first-class theme from day one forces these decisions to be made intentionally — not discovered in QA.
The Three-Tier Token Architecture
A scalable theming system uses a three-tier hierarchy between raw color values and component styles.
Tier 1: Primitive Tokens
Primitive tokens define the complete palette — every color the brand can ever use, with no semantic meaning attached. In the W3C DTCG format (a stable JSON spec that uses $value and $type) they look like named slots in a full tonal scale:
{
"color": {
"blue": {
"100": { "$value": "oklch(96% 0.02 250)", "$type": "color" },
"500": { "$value": "oklch(55% 0.18 250)", "$type": "color" },
"900": { "$value": "oklch(22% 0.08 250)", "$type": "color" }
},
"neutral": {
"0": { "$value": "oklch(100% 0 0)", "$type": "color" },
"50": { "$value": "oklch(97.5% 0.003 250)", "$type": "color" },
"950": { "$value": "oklch(8% 0.005 250)", "$type": "color" }
}
}
}
Notice the color space: OKLCH, not hex or HSL. OKLCH is a perceptually uniform space — equal numeric steps in lightness produce visually equal lightness steps across all hues. That makes it much easier to build algorithmic tonal scales and reason about contrast ratios before you look at a single pixel.
HSL is a legacy encoding format, not a perceptual model. Functions like darken() in Sass, and hand-picked HSL values, both produce uneven and unpredictable tonal ramps.
Tier 2: Semantic Tokens
Semantic tokens give roles to primitive values. They are named for their purpose, not their color. This is the tier where theming actually happens:
{
"color": {
"surface": {
"default": { "$value": "{color.neutral.0}", "$type": "color" },
"subtle": { "$value": "{color.neutral.50}", "$type": "color" },
"overlay": { "$value": "{color.neutral.100}", "$type": "color" },
"raised": { "$value": "{color.neutral.0}", "$type": "color" }
},
"text": {
"primary": { "$value": "{color.neutral.950}", "$type": "color" },
"secondary": { "$value": "{color.neutral.600}", "$type": "color" },
"disabled": { "$value": "{color.neutral.400}", "$type": "color" }
},
"border": {
"default": { "$value": "{color.neutral.200}", "$type": "color" }
}
}
}
In the dark theme, the same semantic token names resolve to different primitive values. color.surface.default becomes oklch(8% 0.005 250) — a near-black, not pure black. color.text.primary becomes a near-white. Components never reference primitives directly; they reference semantic tokens. That means re-theming is a token-level swap with zero component changes.
Tier 3: Component Tokens
Component tokens are optional but valuable in large systems. They give a specific component its own overrides without touching the semantic defaults:
{
"button": {
"background-default": { "$value": "{color.brand.primary}", "$type": "color" },
"background-hover": { "$value": "{color.brand.primary-hover}", "$type": "color" }
}
}
Dark Mode: First-Class Theme vs. Quick Inversion
The gap between a production-quality dark mode and a naïve one comes down to a few decisions made at the token level.
Surface and Elevation
In light mode, depth is communicated with drop shadows. A card appears to sit above the page because its shadow casts downward. On a dark surface, shadows become invisible — there is nothing lighter behind them.
The modern solution is luminance-step elevation: surfaces closer to the user are lighter, not more shadowed.
| Layer | Light mode token | Dark mode token |
|---|---|---|
| Page background | neutral-0 (white) | neutral-950 (~#0A0A0A) |
| Card surface | neutral-0 | neutral-900 |
| Raised panel | neutral-0 + shadow | neutral-850 |
| Modal overlay | neutral-0 + heavy shadow | neutral-800 |
This is why color.surface.raised points to a different primitive in dark mode than in light mode — the semantic name stays stable but the luminance value shifts. Pure black (#000000) is not recommended for reading surfaces. The slight luminance at oklch(8-10% 0.005 250) reduces the apparent glare of text and makes luminance-step elevation viable.
Contrast and WCAG Compliance
WCAG 2.2 AA requires a 4.5:1 contrast ratio for normal text and 3:1 for large text. This is the current legal baseline.
APCA (the proposed model for WCAG 3.0) offers a more perceptually nuanced lens, but it is not yet an adopted standard. Using APCA as a supplement to verify perceptual quality is reasonable. Using it to replace WCAG 2.2 AA compliance exposes your product to legal risk.
Dark mode surfaces with near-black backgrounds make it easy to pass contrast for primary text. Secondary and disabled text, however, require careful design. A common failure is using the same opacity-based approach across both themes — for example, rgba(white, 0.6) for secondary text — without checking the resulting ratio on the actual background. Always check final rendered contrast values, not just the intended alpha.
Interactive States
Hover, focus, and active states are typically defined with lightness shifts. In light mode you darken a button on hover. In dark mode you lighten it. If states are built on fixed primitive references rather than semantic tokens, one theme’s hover state will break the other.
Define color.interactive.hover-overlay as a semantic token whose value is a translucent layer — and give it a different value in each theme. That keeps state logic consistent across all themes.
Do
- Define a separate dark theme file that overrides every semantic token with dark-appropriate primitive values.
- Use near-black surfaces (OKLCH lightness 8-12%) rather than pure black for reading backgrounds.
- Express dark-mode elevation through progressively lighter surface tones, not box shadows.
- Verify all text contrast values against their final dark-mode surfaces, not against a white background assumption.
- Respect
prefers-color-schemeby default and expose a manual override that persists inlocalStorage.
Don't
- Invert light-mode hex values to generate dark-mode values — the result is garish and breaks elevation semantics.
- Use pure #000000 as your background — it causes halation around high-contrast text and eliminates elevation depth.
- Apply box-shadow for elevation in dark mode — shadows are invisible against dark backgrounds.
- Name semantic tokens after color values (
token-gray-200) — the names become false in dark mode. - Ship only a CSS
prefers-color-schememedia query without a user-controlled override — users in bright environments may have a system dark mode and need to opt out.
System Preference Integration and User Control
Dark mode must respond to prefers-color-scheme from the OS — that is table stakes. But it should also give users explicit control. Here is the correct architecture:
- On first visit, read
prefers-color-schemeand apply the matching theme. - Expose a toggle. When the user sets a preference, write it to
localStorage(or to a user account if they are authenticated). - On subsequent visits, read from
localStoragefirst; fall back to the media query.
In CSS, apply the theme via a data-theme attribute on the html element. The media query sets the default; the explicit attribute overrides it:
/* Primitive and component tokens that don't change */
:root {
--color-blue-500: oklch(55% 0.18 250);
}
/* Semantic tokens — light theme defaults */
:root {
--color-surface-default: oklch(100% 0 0);
--color-text-primary: oklch(8% 0.005 250);
}
/* Dark theme semantic overrides via media query */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-surface-default: oklch(8% 0.005 250);
--color-text-primary: oklch(97.5% 0.003 250);
}
}
/* Dark theme semantic overrides via explicit attribute */
[data-theme="dark"] {
--color-surface-default: oklch(8% 0.005 250);
--color-text-primary: oklch(97.5% 0.003 250);
}
This pattern cleanly gives you three states: system-auto (default), forced-light, and forced-dark. The :not([data-theme="light"]) guard prevents a system-dark user who explicitly chose light mode from getting dark theme re-applied via the media query.
Multi-Theme Architecture: Beyond Light and Dark
A robust theming system should accommodate more than two themes. Brand themes for white-label products, a high-contrast accessibility theme (which is different from dark mode), and seasonal or promotional themes all follow the same token-override pattern.
Structure your theme files as complete semantic-token overrides:
tokens/
themes/
light.json # default semantic values
dark.json # dark semantic overrides
high-contrast.json # WCAG AAA-compliant overrides
brand-acme.json # white-label brand overrides
Each file contains only the tokens that change from the base. A build tool like Style Dictionary reads the base and merges each theme, then outputs platform-specific formats — CSS custom properties, iOS Swift constants, Android XML — for each combination.
Token Tooling and the Figma-to-Code Pipeline
The modern token pipeline runs from a single source of truth through to all platforms without manual copy-paste. The W3C DTCG format is the lingua franca that makes this possible: Figma’s Variables, Tokens Studio, and Style Dictionary all support it at varying levels of maturity.
The typical 2026 pipeline:
- Figma Variables hold primitive and semantic tokens. Dev Mode exposes them alongside component code via Code Connect.
- Tokens Studio (or a custom GitHub Action) exports variables to DTCG-format JSON and commits them to the token repository.
- Style Dictionary transforms the JSON into CSS custom properties, iOS Swift enums, Android XML resource files, and whatever else each platform needs.
- Storybook consumes the CSS output and renders all components in every theme, serving as the living handoff layer.
What this replaces: separate Zeplin redline PDFs, hand-maintained colors.ts files per platform, Sass variable files that drift from the Figma source. Outdated workflows let tokens diverge between design and engineering within weeks of a design system launch.
Accessibility Considerations for Themed Interfaces
Themes introduce accessibility complexity that single-theme systems avoid. A few important checks:
- Check every semantic role in every theme. A focus ring visible on a light surface may be invisible on a dark surface if it relies on a fixed hex value rather than a semantic token.
- Respect
prefers-reduced-motionwhen transitioning between themes. If you animate the theme transition (a fade or cross-fade), suppress that animation when the user has requested reduced motion. - Test with forced-colors / Windows High Contrast Mode. The
forced-colorsmedia query overrides your custom properties with OS-level colors. Designs that rely purely on background color to convey state — without border or shape changes — will fail in this mode. - Ensure focus indicators meet WCAG 2.2 SC 2.4.11 (Focus Not Obscured). Sticky headers and floating UI in dark interfaces often obscure focus indicators. Dark themes make this harder to spot in reviews.
/* Forced-colors safe focus ring */
:focus-visible {
outline: 2px solid var(--color-border-focus);
outline-offset: 2px;
}
@media (forced-colors: active) {
:focus-visible {
outline: 3px solid ButtonText;
}
}
Performance and the CSS Custom Property Strategy
CSS custom properties are the right primitive for theming in 2026. Not CSS-in-JS runtime theme objects. Not Sass variables, which are static at compile time. And not class-based theme switching, which requires toggling hundreds of classes simultaneously.
Custom properties cascade and inherit. A single data-theme attribute on html propagates every semantic token update to every element in the tree in one browser paint. There is no JavaScript overhead at runtime. The browser does the cascading work natively.
For very large systems with hundreds of tokens, split theme files into logical CSS layers — primitives in one layer, semantics in another, component tokens in a third. This keeps specificity manageable and avoids the silent specificity bugs that cause theme values to fail to override correctly.