Implementing prefers-color-scheme without FOUC

Architecting Critical CSS for Immediate Theme Resolution

To eliminate Flash of Unstyled Content (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:

  1. 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.
  2. Synchronous attribute injection: Evaluate window.matchMedia('(prefers-color-scheme: dark)') and read localStorage synchronously. Apply a data-theme attribute to the <html> element before the parser reaches the <body>.
  3. 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.

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:

  1. Define Primitive Tokens: Establish base color primitives at the :root level. Never hardcode hex values in component stylesheets.
  2. Construct Semantic Fallback Chains: Map semantic tokens (e.g., --color-surface-primary) to primitives using CSS variable fallback syntax.
  3. 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.

CI Pipeline Validation and Hydration Debugging

Automated testing must simulate system-level theme preferences to catch hydration mismatches before deployment.

// 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');
});

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.

Migration Strategy: From JS Toggles to Native Media Queries

Migrating legacy JavaScript-driven theme toggles to native CSS requires a phased rollout.

  1. Decouple Framework State: Remove theme state from React/Vue context providers. Migrate to CSS custom properties bound directly to the data-theme attribute on <html>.
  2. Replace Storage Listeners: Deprecate window.addEventListener('storage') polling. Implement MediaQueryList.addEventListener('change') for real-time, event-driven system preference updates.
  3. Audit & Refactor Component Styles: Run a static analysis pass to identify hardcoded color values (#hex, rgb(), hsl()). Refactor them to use the established token fallback strategy.
  4. Enforce CSS Specificity Boundaries: Ensure @media queries and [data-theme] selectors operate at the root level. Avoid inline styles or utility-class overrides that break the cascade.

Diagnostic Framework

Category Detail
Diagnostic Steps 1. Check that the inline resolver script appears in the raw HTML <head> before any <body> content.
2. In DevTools Performance, record page load and look for [data-theme] being set on <html> before the first Layout event.
3. Compare server-rendered data-theme against the client’s matchMedia result to find SSR mismatches.
4. Run Lighthouse to detect FOUC-induced CLS penalties.
Root Causes • Theme resolver placed in an external src= script instead of inline.
• Missing try/catch around localStorage causing the resolver to throw in restricted environments.
• SSR defaults to light while client OS prefers dark, with no cookie/header reconciliation.
• CSS specificity conflict between @media and [data-theme] selectors.
Resolution Patterns • Inline the resolver; keep it under ~200 bytes.
• Use :root:not([data-theme="light"]) inside dark-mode @media to avoid conflicts with explicit light override.
• Use the Sec-CH-Prefers-Color-Scheme client hint header on the server to pre-set data-theme in the SSR payload.
• Pair @media with [data-theme] selectors at equal specificity to keep overrides predictable.

Migration Phases

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.

Phase 3: Validation Add Playwright theme emulation to the CI pipeline. Implement visual regression testing with prefers-color-scheme toggled across all breakpoints. Monitor hydration mismatch logs.

Phase 4: Deployment Enable gradual rollout via feature flag. Monitor Core Web Vitals (CLS, FCP) for FOUC regression. Rollback if hydration mismatch rate exceeds 0.5%.