light-dark() and color-mix(): Theming Without a Flash, Without JavaScript
The specific bug JS-based theme toggles produce is a flash of the wrong theme on load - a different failure from general FOUC, and the distinction matters for how you fix it. light-dark() plus color-scheme render the correct theme in the first paint, and color-mix() derives an opacity scale from it without a build step.
A precision problem first, because getting it wrong undermines everything that follows.
Flash of Unstyled Content (FOUC) is what happens when a page renders before its stylesheet has loaded - the browser paints in its default styles, then repaints once CSS arrives. It’s a resource-loading-order problem.
The theme flash that JavaScript-based dark mode produces is a different bug. The stylesheet has loaded. The page is fully styled. It’s styled wrong - light mode renders first, then a script runs after hydration or after DOMContentLoaded, adds a dark class, and the page repaints into the correct theme. The content was never unstyled; it was styled with the wrong values for a moment.
Call the second one what it is - a flash of incorrect theme, not FOUC - because the fix for FOUC (get your CSS to load faster) does nothing for a theme flash caused by a script running after paint. Conflating the two is a common enough error that it’s worth naming precisely before touching a line of code.
Why the theme flash happens
The standard client-side dark mode implementation:
// Runs after the page has already painted in the default theme.
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', stored === 'dark' || (!stored && prefersDark));
Even inlined and placed early in <head>, this still executes after the browser has parsed enough HTML to begin painting, and it still requires JavaScript to run before the correct theme applies. On a slow connection, or with JavaScript disabled, or during a moment of main-thread contention, the gap between first paint and script execution is visible - a flash of light mode before the dark class lands.
The workaround culture around this - inline blocking scripts in <head>, visibility: hidden on <html> until the script runs - treats the symptom. The theme decision doesn’t need JavaScript at all.
light-dark() and color-scheme
:root {
color-scheme: light dark;
--bg: light-dark(#ffffff, #0a0a0a);
--text: light-dark(#1a1a1a, #f0f0f0);
--border: light-dark(#e2e2e2, #2a2a2a);
}
body {
background: var(--bg);
color: var(--text);
}
light-dark(lightValue, darkValue) picks a value based on the computed color-scheme of the nearest ancestor that declares one. color-scheme: light dark tells the browser this element supports both, and the browser resolves the choice from the user’s OS-level preference before first paint - no script, no post-load class toggle, no flash, because there’s no second pass.
The critical mechanism worth calling out explicitly: this happens during CSS resolution, before any JavaScript runs. The theme isn’t applied after the page loads. It’s correct in the first paint, the same way any other computed style is.
Overriding the OS preference (a manual toggle)
The system preference resolves automatically. A user-facing light/dark toggle needs one property set, and it can be set with zero JavaScript for the initial page load if you use a form control, or with a single property write if you want a button:
/* Default: follow the OS */
:root { color-scheme: light dark; }
/* Explicit override, e.g. from a data attribute your toggle sets */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
// Only runs on user interaction - never on page load.
// Persisting the choice server-side (a cookie read before render)
// avoids reintroducing the flash for returning visitors; see the
// edge-personalization article on this site for that pattern.
function setTheme(theme) {
document.documentElement.dataset.theme = theme;
document.cookie = `theme=${theme}; max-age=31536000; path=/; SameSite=Lax`;
}
Note what moved: the JavaScript here runs only in response to a click, never on load. Nothing paints, then repaints. The persistence-on-return-visit problem - a returning user whose stored preference should apply on the next page load, before any script runs - is a server-side read-the-cookie-and-set-the-attribute problem, not a client-side one. That’s the edge-rendering pattern covered elsewhere on this site, and it’s the correct fix here for the same reason it’s correct for pricing: decide before paint, not after.
color-mix(): deriving a scale instead of hand-authoring one
Design systems typically need more than two colors per role - an opacity or tint scale for hover states, disabled states, subtle backgrounds. Hand-authoring each step for both light and dark is a maintenance burden that grows with every new semantic color.
color-mix() derives intermediate values at resolution time:
:root {
--accent: light-dark(#2563eb, #60a5fa);
/* Each step mixes --accent toward transparent, calculated once
per theme by the browser, not hand-picked per theme. */
--accent-10: color-mix(in oklch, var(--accent) 10%, transparent);
--accent-25: color-mix(in oklch, var(--accent) 25%, transparent);
--accent-50: color-mix(in oklch, var(--accent) 50%, transparent);
}
.button--ghost:hover { background: var(--accent-10); }
.badge--subtle { background: var(--accent-10); border-color: var(--accent-25); }
.overlay { background: var(--accent-50); }
Change --accent for a rebrand and the entire opacity scale recalculates automatically, correctly, in both themes, with zero additional declarations.
Use oklch as the interpolation space, not the default. color-mix()’s default interpolation is sRGB, which produces visibly muddy, desaturated midpoints when mixing toward transparent or between hues. oklch is perceptually uniform - a 50% mix looks like the visual midpoint, not a grayed-out compromise:
/* Muddy midpoint */
color-mix(in srgb, var(--accent), transparent);
/* Perceptually correct midpoint */
color-mix(in oklch, var(--accent), transparent);
A state-color scale from two inputs
:root {
--success: light-dark(#16a34a, #4ade80);
--danger: light-dark(#dc2626, #f87171);
--success-bg: color-mix(in oklch, var(--success) 12%, var(--bg));
--success-border: color-mix(in oklch, var(--success) 40%, var(--bg));
--danger-bg: color-mix(in oklch, var(--danger) 12%, var(--bg));
--danger-border: color-mix(in oklch, var(--danger) 40%, var(--bg));
}
Mixing toward --bg rather than toward transparent keeps these solid, theme-correct fills instead of translucent overlays - the right choice for alert banners and status badges, where you want a flat color that already accounts for the current background.
Every value here is computed by the browser’s compositor at resolution time. None of it runs on the main thread as JavaScript, none of it requires a build step to pre-generate a palette, and none of it needs regenerating when --accent changes.
Browser support
light-dark() and color-mix() both reached Baseline widely available status by 2024 - Chrome/Edge 111+, Firefox 113+, Safari 16.4+. This is settled, shipped, unconditional-use territory as of this writing; no fallback pattern is required for a modern browser matrix.
For teams that must support older engines, a build-time transform exists: Lightning CSS compiles light-dark() down to a color-scheme-driven CSS variable fallback for targets that lack native support. That’s a build-tool decision, not a reason to avoid the native syntax in code you’re writing today.
What this doesn’t replace
Two things worth being precise about, because overclaiming here is exactly the failure mode this site exists to avoid.
It doesn’t replace prefers-reduced-motion, prefers-contrast, or other user-preference media features. light-dark() addresses one preference - color scheme. A complete accessibility-aware theme still needs those separately.
It doesn’t make a manual toggle instant on the very first visit from a JavaScript-free request if you’re relying on a client-set cookie. The color-scheme OS-preference path is instant and script-free by construction. A manual override that a returning visitor expects to persist needs that preference available to the server before it renders the page - which means reading it from a cookie server-side, not from localStorage, which the server cannot see. This is the same architectural point made about pricing and experiment cookies elsewhere on this site: decisions that must be correct on first paint belong on the server or in CSS the browser resolves natively, not in a script that runs after.
Frequently asked questions
Does light-dark() prevent FOUC?
Not the general kind - FOUC is caused by CSS loading after HTML paints, and light-dark() doesn’t touch load ordering. What it prevents is the flash of the wrong theme: the specific failure where JavaScript applies a dark-mode class after the page has already painted in light mode. Different bug, and it’s worth keeping the terms distinct.
Do I still need JavaScript for a theme toggle?
Only for the interaction itself - the click that changes the preference. The initial render, whether following OS preference via color-scheme or a previously-set server-readable cookie, needs no JavaScript at all.
Why oklch instead of the default srgb interpolation in color-mix()?
srgb interpolation produces visibly muddy midpoints, especially mixing toward transparent or across hues. oklch is perceptually uniform, so a 50% mix looks like an actual visual midpoint.
What’s the browser support?
Both features are Baseline widely available as of 2024 - Chrome/Edge 111+, Firefox 113+, Safari 16.4+. No fallback is needed for a modern support matrix; Lightning CSS offers a build-time fallback for older targets if you need one.
Does this eliminate the need for a design-token build pipeline?
For color derivation specifically, largely yes - color-mix() computes derived values at resolution time rather than requiring a script to pre-generate a palette. It doesn’t replace a token pipeline’s other jobs: naming, documentation, cross-platform export.
Sources
- MDN:
light-dark() - MDN:
color-mix() - MDN:
color-scheme - Lightning CSS v1.24.0 release notes - build-time fallback behaviour
- Wikipedia: Flash of unstyled content - for the FOUC definition this article deliberately does not conflate with theme flash