Implementing prefers-color-scheme Without FOUC
Part of Prefers-Color-Scheme Integration. This page covers the exact technique for resolving the correct color scheme token set before the browser paints a single pixel — eliminating the Flash of Unstyled Content that occurs when theme state is applied too late in the load sequence.
data-theme on <html> synchronously after critical CSS and before first layout, so the correct token set is active for every paint frame.Prerequisites
- CSS custom properties structured in at least two tiers: primitive color values and semantic aliases (
--color-surface-primary,--color-text-primary, etc.). - A
<head>you control — server-rendered HTML or a build step that injects inline scripts. Fully client-side SPAs with a lockedindex.htmlrequire workarounds noted in the Migration note below. localStorageavailable in the target environments (or a graceful fallback for environments where it is blocked — private-browsing mode, sandboxed iframes).- Playwright ≥ 1.30 or equivalent E2E tooling that can emulate
prefers-color-scheme. - No Content Security Policy that blocks
unsafe-inlinescripts — if CSP is strict, anonceattribute must be pre-generated per response.
Architecting Critical CSS for Immediate Theme Resolution
To eliminate FOUC during initial paint, theme resolution must execute synchronously before the main thread parses the DOM. The inline script approach below guarantees that data-theme is set on <html> before any element is laid out.
Implementation sequence:
- Inline the resolver — never use
src=: Place a minimal, blocking inline<script>directly inside the<head>tag, immediately after any critical CSS. An external script (<script src="...">) still requires a network round-trip before it can execute, which defeats the purpose. - Synchronous attribute injection: Evaluate
window.matchMedia('(prefers-color-scheme: dark)')and readlocalStoragesynchronously. Apply adata-themeattribute to the<html>element before the parser reaches the<body>. - Keep the script small: The resolver should do nothing except read storage and apply the attribute. Heavy logic,
fetch(), or framework hooks must stay out of this script.
<head>
<!-- Critical theme CSS inlined above this script -->
<script>
(function() {
try {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute(
'data-theme',
stored || (prefersDark ? 'dark' : 'light')
);
} catch (e) { /* localStorage may be blocked in private-browsing or sandboxed iframes */ }
})();
</script>
</head>
This synchronous injection guarantees CSS variables resolve correctly before layout calculation begins. Reading localStorage here is correct and necessary: it lets explicit user preference override the OS default on every page load, which is the expected behavior for a theme toggle. For the full logic of keeping that stored preference in sync with system changes over time, see syncing system preference with a manual theme override.
Token Synchronization and CSS Variable Fallback Chains
Design systems require strict token mapping to prevent visual regression when system preferences shift. Implement a deterministic fallback architecture to handle browser inconsistencies and hydration states:
- Define Primitive Tokens: Establish base color primitives at the
:rootlevel. Never hardcode hex values in component stylesheets. - Construct Semantic Fallback Chains: Map semantic tokens (e.g.,
--color-surface-primary) to primitives using CSS variable fallback syntax. - Bind Media Queries and Data Attributes: Use
@media (prefers-color-scheme: dark)to override root variables when no explicit choice exists, and[data-theme="dark"]to honor an explicit user selection regardless of OS preference.
:root {
--color-surface-primary: #ffffff;
--color-text-primary: #111111;
}
/* Applies when OS is dark AND user has not explicitly chosen a theme */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-surface-primary: #0f0f0f;
--color-text-primary: #f5f5f5;
}
}
/* Explicit user override — highest priority */
[data-theme="dark"] {
--color-surface-primary: #0f0f0f;
--color-text-primary: #f5f5f5;
}
[data-theme="light"] {
--color-surface-primary: #ffffff;
--color-text-primary: #111111;
}
Using :root:not([data-theme="light"]) inside the media query prevents a conflict when the user has explicitly chosen light mode on a dark-OS machine. Without it, the @media block and [data-theme="light"] can conflict depending on specificity. If you are also handling the case where the user’s stored preference should survive a page reload without a flash, the same token structure applies — see persisting user theme choice without a flash on reload for the server-side cookie strategy that extends this approach.
Verification
Confirm the implementation is working at three layers:
DevTools Performance trace: Record a fresh page load with cache disabled. In the flame chart, locate the first Layout event. The [data-theme] attribute must already be present on <html> before that event fires. If it appears after the first Layout, the resolver is running too late — check that no defer or async attribute is on the script tag.
Lighthouse: Run a Lighthouse audit with dark mode emulated. A FOUC registers as a Cumulative Layout Shift (CLS) spike in the first few frames. CLS scores above 0.05 on initial load with a dark OS preference are a strong signal that the resolver is missing or deferred.
Playwright E2E test:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
colorScheme: 'dark', // Emulate system preference
},
testDir: './e2e',
});
// e2e/theme.spec.ts
import { test, expect } from '@playwright/test';
test('dark mode resolves without FOUC', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/');
const htmlTheme = await page.evaluate(() =>
document.documentElement.getAttribute('data-theme')
);
expect(htmlTheme).toBe('dark');
await expect(page.locator('body')).toHaveScreenshot('dark-mode-initial-paint.png');
});
The screenshot assertion catches visual regressions that attribute checks miss — a data-theme might be set correctly while a token mapping is still wrong.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Flash of light background on dark-OS page load | Resolver script has async or defer, or is loaded via src= |
Remove async/defer; inline the full resolver body in <head> |
data-theme correct, but colors still flash |
Critical CSS is not inlined — it loads from an external file after resolver fires | Inline the :root token block and the [data-theme] selectors as a <style> in <head> |
localStorage throws in CI or iframes |
Resolver missing try/catch around storage read |
Wrap the entire resolver body in try { … } catch (e) {} |
| Dark-OS user with light preference sees dark flash | @media (prefers-color-scheme: dark) block overrides [data-theme="light"] |
Add :not([data-theme="light"]) guard to the @media rule |
SSR page arrives with wrong data-theme |
Server defaults to light without reading the Sec-CH-Prefers-Color-Scheme client hint or cookie |
Read the hint or a theme cookie in the server handler; set data-theme in the SSR HTML payload |
Root causes for FOUC in CI typically stem from server-rendered HTML lacking the initial data-theme attribute, causing client-side hydration to overwrite styles mid-paint. Implement a deterministic SSR fallback that defaults based on the Sec-CH-Prefers-Color-Scheme request header (where available), then reconciles on mount using the synchronous head script. For a full treatment of the SSR reconciliation problem, see handling SSR hydration mismatches in dark mode.
Migration Note
Migrating from JS-driven theme toggles to native media queries:
Phase 1 — Audit. Extract all hardcoded color values into a design token registry. Map legacy JS theme state to CSS custom properties. Establish a baseline visual regression suite.
Phase 2 — Implementation. Replace runtime JS theme injection with the synchronous inline head script. Configure @media (prefers-color-scheme) as the OS-preference driver. Remove framework-level theme context providers.
Concretely: remove theme state from React/Vue context providers. Migrate to CSS custom properties bound directly to the data-theme attribute on <html>. Deprecate window.addEventListener('storage') polling; use MediaQueryList.addEventListener('change') for real-time, event-driven system preference updates.
Phase 3 — Validation. Add Playwright theme emulation to the CI pipeline. Implement visual regression testing with prefers-color-scheme toggled across all breakpoints. Run a static analysis pass to identify remaining hardcoded color values (#hex, rgb(), hsl()) and refactor them to use the established token fallback strategy. Monitor hydration mismatch logs.
Phase 4 — Deployment. Enable gradual rollout via feature flag. Monitor Core Web Vitals (CLS, FCP) for FOUC regression. Roll back if hydration mismatch rate exceeds 0.5%.
Enforce CSS specificity boundaries throughout: ensure @media queries and [data-theme] selectors operate at the root level. Avoid inline styles or utility-class overrides that break the cascade.
Frequently Asked Questions
Why can’t I just use @media (prefers-color-scheme: dark) in CSS without any script?
A pure @media approach works for users who have never overridden the theme — the OS preference drives everything. It breaks the moment you add a manual toggle: the user switches to light mode, you store that in localStorage, and on the next page load the @media block fires immediately (applying dark tokens) while JS hasn’t run yet to read the stored preference. That gap is the FOUC. The inline resolver closes it by reading localStorage before any paint happens.
Does the inline script block rendering?
Yes, intentionally. A blocking script in <head> pauses HTML parsing until it finishes executing. Because the resolver is under 200 bytes and does no I/O, the pause is sub-millisecond. The cost of that micro-block is far lower than the cost of a visible theme flash. Never mark this script async or defer.
What if Content Security Policy blocks inline scripts?
Generate a per-request nonce in your server handler, add it to the <script nonce="..."> tag, and include it in the Content-Security-Policy: script-src 'nonce-...' header. The nonce must be unique per response and must never appear in the HTML as a static value.
Related
- Prefers-Color-Scheme Integration — parent page covering the full scope of OS preference integration in design systems.
- Syncing system preference with a manual theme override — sibling page on keeping the
MediaQueryListchange event in sync with user-stored overrides at runtime. - Persisting user theme choice without a flash on reload — cousin page covering the server-side cookie strategy that eliminates the flash even before client JS runs.