#React#TypeScript

CSS Dark Mode Implementation: prefers-color-scheme and Class Switching

Dark mode isn't simply inverting colors—you have to handle system-preference following, manual switching, persistence, flash (FOUC), and image/icon adaptation. This article covers the prefers-color-scheme media query, class-switching strategy, localStorage persistence, anti-flash scripts, and a CSS-variable theme system, delivering a complete dark-mode

Key Points

  • Topic Focus: React、TypeScript
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

CSS Dark Mode Implementation: prefers-color-scheme and Class Switching

"Dark mode isn't as simple as flipping black text on white—it's the litmus test for an entire 'theme system'."

Many developers have two misconceptions about dark mode: first, thinking it's "just filter: invert(1), right?", and second, thinking "just add @media (prefers-color-scheme: dark) and you're done."

Once you've actually done it, you'll find all the traps hiding in the details:

  • The user's system is dark, but they want to see your site in light—do you follow along?
  • The user manually switches to dark, and after refreshing the page it "white-flashes" once before going dark—this is called FOUC (Flash of Unstyled Content), the disaster zone for dark-mode bad reviews.
  • The same product hero image looks vivid in light mode but glaring in dark mode, like staring into headlights—what do you do?
  • Under an SSR framework (Next.js), the server has no idea what the user's theme is, so the first screen is bound to flash—how do you break this?

This article doesn't explain "what dark mode is"; instead it covers how to engineer a dark-mode implementation that doesn't fight itself, doesn't flash, can persist, and can follow the system. After reading you should be able to:

  • Distinguish between the prefers-color-scheme media query and class-switching strategies, and know when to use each
  • Design a three-state model of "system / light / dark", rather than a crude binary switch
  • Write an anti-FOUC inline script that serves the correct colors from the very first frame
  • Build a semantic theme system with CSS variables, fully decoupling components from the theme
  • Use next-themes correctly in Next.js, rather than reinventing the wheel yourself

1. prefers-color-scheme: Following System Preference, the Easiest Starting Point

Modern operating systems (macOS, Windows, iOS, Android) all have a "system-level dark mode". The browser exposes the system preference to CSS and JS through the prefers-color-scheme media feature.

1.1 The Simplest Dark Mode: Pure CSS Following the System

If you don't need manual switching at all—the user's system is dark, so your site is dark—then you only need CSS, without a single line of JS:

css
:root {
  --bg: #ffffff;
  --text: #1a1a1a;
  --border: #e5e5e5;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0a0a0a;
    --text: #ededed;
    --border: #2a2a2a;
  }
}

body {
  background-color: var(--bg);
  color: var(--text);
}

That's the whole magic of prefers-color-scheme. The browser (including Safari iOS 13+, Chrome 76+, Firefox 67+) automatically responds to system theme changes, and when the user changes the system setting, your page follows without needing a refresh.

1.2 Three Values, Not Two

prefers-color-scheme has three values—don't fixate only on dark:

css
/* User has expressed no preference (appears under a few systems/accessibility settings) */
@media (prefers-color-scheme: no-preference) {
  /* Usually nothing to do; just go with the default (light) */
}

@media (prefers-color-scheme: light) {
  /* Explicit light */
}

@media (prefers-color-scheme: dark) {
  /* Explicit dark */
}

The no-preference value is easy to overlook. The safe approach is: the default values (the set on :root outside the media query) are light, treating dark as an override. This way no-preference naturally falls into light, avoiding the accident of "no preference but everything goes black".

1.3 How to Sense the System Theme on the JS Side

CSS alone isn't enough—for example, if your toggle button wants to show whether it's currently light or dark, or you want to listen for system-switching events, you need JS's matchMedia:

ts
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const systemPrefersDark = mql.matches;

// Listen for real-time system theme changes (triggered when the user switches in system settings)
mql.addEventListener('change', (e) => {
  console.log('System switched to', e.matches ? 'dark' : 'light');
});

Note that it's addEventListener('change', ...); the old addListener is deprecated—don't use it anymore.

1.4 The Fatal Limitation of Pure prefers-color-scheme

This approach's biggest problem is: the user has no choice.

In many scenarios, users want "my system is dark, but I want to see this site in light" (for example, reading code for a long time, or viewing design mockups that require precise color discrimination). A pure media query can't do this—it treats the system preference as gospel, and the user can't override it within your site.

So in real-world engineering, prefers-color-scheme is almost never used alone; it's just one input source in the "three-state model". The real core is the class-switching strategy in the next section.


2. Manual Switching + Class Strategy: Returning Control to the User

2.1 Why Class, Not Something Else

There are several ways to implement manual switching: toggle a data-theme attribute, swap different <link> stylesheets, change a name="color-scheme" meta... but the de facto community standard is—adding a dark (or similarly named) class on <html>.

Why? Three reasons:

  1. Best selector performance/simplicity: .dark .card { ... } is slightly faster than the attribute selector [data-theme="dark"] .card, and more readable.
  2. Natural compatibility with ecosystems like Tailwind / shadcn: Tailwind's dark: variant defaults to the class strategy (darkMode: 'class').
  3. Most natural CSS-variable override: see section five—.dark { --bg: ... } overrides the entire set of variables in one line.
css
/* Default (light) variables defined on :root */
:root {
  --bg: #ffffff;
  --text: #1a1a1a;
}

/* Dark override: as long as html has the .dark class, the whole site's variables switch */
.dark {
  --bg: #0a0a0a;
  --text: #ededed;
}

2.2 Three-State Model: Don't Use a Boolean, Use 'light' | 'dark' | 'system'

The most common beginner mistake is managing the theme with a boolean isDark. This loses the "follow system" state.

The correct model is three-state:

ts
type Theme = 'light' | 'dark' | 'system';
  • light: force light, ignoring the system.
  • dark: force dark, ignoring the system.
  • system: follow the system (the default value, determined via prefers-color-scheme).

What's stored for the user's choice is one of these three values, while the class actually applied to the DOM is the resolved result. This point is critical and will come up again during persistence.

2.3 Switching Logic: Resolve the Three States into the Actual Class

ts
const STORAGE_KEY = 'theme';

function getSystemTheme(): 'light' | 'dark' {
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

function applyTheme(theme: Theme) {
  // 1. Resolve the three states into actual light/dark
  const resolved = theme === 'system' ? getSystemTheme() : theme;

  // 2. Toggle the class on html
  document.documentElement.classList.toggle('dark', resolved === 'dark');

  // 3. Also set the color-scheme so form controls and scrollbars follow
  document.documentElement.style.colorScheme = resolved;
}

function setTheme(theme: Theme) {
  localStorage.setItem(STORAGE_KEY, theme);
  applyTheme(theme);
}

There's a frequently overlooked detail here: the color-scheme CSS property (or style.colorScheme). Once set, the browser's native scrollbars, <input>, <select>, and other "UA controls" will actually take on dark styling. Without this line you'll see a shiny light scrollbar floating on a dark page—maximum visual dissonance.

css
/* Or add a fallback in CSS as well */
:root { color-scheme: light; }
.dark { color-scheme: dark; }

2.4 The Toggle Button: A Pitfall—Don't Render the Current State Before Hydration

When writing the toggle button in React/Next.js, another pitfall is: the server doesn't know the current theme, so the icon rendered on the first screen (🌙 or ☀️) may well be inconsistent with the client, causing a hydration mismatch warning.

tsx
'use client';
import { useEffect, useState } from 'react';

export function ThemeToggle() {
  const [mounted, setMounted] = useState(false);
  const [isDark, setIsDark] = useState(false);

  useEffect(() => {
    setMounted(true);
    setIsDark(document.documentElement.classList.contains('dark'));
  }, []);

  // Key: render a placeholder before mounting to avoid server/client icon mismatch
  if (!mounted) return <button className="w-9 h-9" aria-label="Toggle theme" />;

  return (
    <button
      onClick={() => {
        const next = isDark ? 'light' : 'dark';
        setTheme(next);
        setIsDark(!isDark);
      }}
      aria-label={isDark ? 'Switch to light' : 'Switch to dark'}
    >
      {isDark ? '☀️' : '🌙'}
    </button>
  );
}

The mounted guard looks unglamorous, but it's the standard pattern in Next.js for handling "state that only the client knows". As we'll see later, next-themes can't escape it either.


3. Persistence: localStorage Remembers the User's Choice

3.1 What to Store and What Not to Store

Implementing persistence is simple—localStorage.getItem('theme') / setItem. But what you store matters:

  • ✅ Store one of the three states: 'light' | 'dark' | 'system'. This way, if the user chose "follow system", it still follows next time.
  • ❌ Don't store the resolved 'light' | 'dark'. If you store the resolved result, and the user's system switches to dark during the day, the site will still be light when they open it next time—"follow system" exists in name only.

Remember one principle: localStorage stores the user's "intent", not "the computed result at the time".

3.2 Complete Reading and Fallback

ts
function getStoredTheme(): Theme {
  if (typeof window === 'undefined') return 'system';
  const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
  // Validate legality to guard against dirty data
  return stored === 'light' || stored === 'dark' ? stored : 'system';
}

Note the legality validation. Users might write garbage through devtools, older versions may have stored other keys, and in private mode localStorage may throw directly—these edge cases must all be handled.

3.3 When the System Switches, "Follow System" Users Should Follow Too

When combining persistence + follow-system, there's another easily missed point: when the user's theme is system and the system switches, you have to re-apply.

ts
useEffect(() => {
  const mql = window.matchMedia('(prefers-color-scheme: dark)');
  const onChange = () => {
    if (getStoredTheme() === 'system') {
      applyTheme('system');
    }
  };
  mql.addEventListener('change', onChange);
  return () => mql.removeEventListener('change', onChange);
}, []);

Without this snippet, "follow system" users who switch their system theme must refresh the page for it to take effect—a broken experience.


4. Anti-FOUC: The White Flash Is Dark Mode's Number-One Killer

The logic so far is fine, but as soon as you run it on an SSR framework you're bound to hit a wall—FOUC (Flash of Unstyled Content), colloquially the "white flash".

4.1 How the White Flash Is Produced

Taking Next.js as an example, the timeline looks like this:

  1. The server renders HTML, and the server doesn't know the user's theme, so it can only render with the default (light).
  2. The browser receives the HTML and paints the first frame as light first.
  3. Then the JS bundle downloads, parses, and executes; inside useEffect it reads localStorage and adds the dark class.
  4. The page goes from light to dark—the user sees a white flash.

For dark-mode users, a white flash on every refresh is an extremely eye-straining experience (especially at night). The root cause of this problem is: the logic that decides the theme runs after the first frame is painted.

4.2 The Fix: Inject a Synchronous Blocking Script Before CSS Is Applied and Before the First Frame Is Painted

The idea is—stuff a synchronous (not async/defer) inline <script> into <head>; it finishes executing before the browser parses <body>, adding the dark class to <html> ahead of time. This way, when the first frame is painted, the colors are already correct.

html
<!doctype html>
<html lang="zh">
  <head>
    <!-- This script must execute synchronously, and be placed before all CSS / as early as possible -->
    <script>
      (function () {
        try {
          var stored = localStorage.getItem('theme');
          var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
          var isDark = stored === 'dark' || (!stored || stored === 'system') && systemDark;
          if (isDark) document.documentElement.classList.add('dark');
          document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
        } catch (e) {}
      })();
    </script>
    <!-- Subsequent CSS, JS resources -->
  </head>
  <body><!-- ... --></body>
</html>

Key points:

  • Must be inline, not an external file (an external file adds a network request and can't make the first frame).
  • Must be synchronous, not defer/async; it must block parsing until it finishes.
  • Must be in <head>, as early as possible, so it completes before <body> renders.
  • The logic must be exactly consistent with applyTheme in React, otherwise you'll get a secondary flash of "the inline script judges dark, then React hydration turns it light again". Keeping both sides' logic from the same source is the discipline of anti-FOUC.
  • Wrap it in try/catch (private mode or disabled localStorage will throw); on failure, silently fall back to the system judgment—don't let the script crash and affect the whole page.

4.3 Use color-scheme as a Fallback, So Even If the Script Fails It Won't Be All-White

Even if that inline script doesn't take effect for some reason (say it's blocked by CSP), you can let the browser fall back to the system preference first, avoiding a "snow-white" page:

html
<meta name="color-scheme" content="light dark" />

After declaring content="light dark", the browser renders UA controls and the default background according to the system preference before CSS is applied—essentially an extra layer of insurance. It's strongly recommended as a base configuration written into every <head>.

4.4 An Anti-Pattern: Forcing a Switch with a CSS Media Query, Which Then Fights the Manual Choice

To prevent flashing, some people write @media (prefers-color-scheme: dark) { body { background: #000; } }, trying to lay down a dark background with pure CSS first. But this fights the class strategy—when the user chooses "system is dark but I want this site light", the CSS media query still paints the background black, splitting the logic.

The discipline is: the theme source can have only one source of truth. Either follow the system entirely (pure media query) or follow the class entirely (manual strategy + inline script)—don't mix the two.


5. CSS-Variable Theme System: Fully Decoupling Components from the Theme

Whether dark mode is done well is seventy percent determined by your CSS-variable abstraction layer.

5.1 Anti-Example: Hardcoding Colors in Components

css
/* Never write it this way */
.card {
  background: #ffffff;
  color: #1a1a1a;
  border: 1px solid #e5e5e5;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.dark .card {
  background: #1a1a1a;
  color: #ededed;
  border: 1px solid #2a2a2a;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}

This approach means every component has to be written twice—once for light, once for dark. Once components multiply, the maintenance cost rises exponentially; changing one color means changing N places, and sooner or later you'll miss one.

5.2 The Right Approach: Semantic Tokens, Components Know Only Tokens Not Color Values

The correct approach is to establish a set of semantic design tokens—naming colors "by purpose" rather than "by hue":

css
:root {
  /* === Base palette: raw color values, not used directly by components === */
  --gray-50: #fafafa;
  --gray-900: #111111;
  --gray-200: #e5e5e5;
  --gray-800: #262626;

  /* === Semantic tokens: components reference only these === */
  --color-bg: #ffffff;            /* page background */
  --color-surface: #ffffff;       /* card/panel background */
  --color-text: var(--gray-900);  /* primary text */
  --color-text-muted: #6b6b6b;    /* secondary text */
  --color-border: var(--gray-200);
  --color-primary: #2563eb;       /* brand primary color */
  --color-primary-hover: #1d4ed8;
  --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.1);
}

.dark {
  --color-bg: #0a0a0a;
  --color-surface: #161616;
  --color-text: #ededed;
  --color-text-muted: #9a9a9a;
  --color-border: var(--gray-800);
  --color-primary: #3b82f6;        /* In dark mode, lighten the primary slightly to ensure contrast */
  --color-primary-hover: #60a5fa;
  --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.5);  /* In dark mode, shadows are deeper */
}

On the component side, reference only semantic tokens, knowing nothing about light or dark:

css
.card {
  background: var(--color-surface);
  color: var(--color-text);
  border: 1px solid var(--color-border);
  box-shadow: var(--shadow-card);
}

The payoff is structural:

  • Switching themes = swapping a set of variables; the component CSS doesn't change a single line.
  • Changing the theme = changing the variable definitions; one change takes effect globally.
  • To add a third theme (say a "high-contrast mode"), just add another set of variables, with zero changes to the component code.

5.3 Dark Mode Isn't Inverting Colors—Mind Contrast and Hierarchy

What's easiest to get wrong in dark mode is contrast and hierarchy.

A few rules of thumb:

  1. Don't use pure black #000000 for the dark background. Pure black makes shadows completely ineffective (you can't see a shadow darker than black on black), destroying all hierarchy. A deep gray like #0a0a0a ~ #1a1a1a that's "near-black but leaves room for shadows" is recommended.
  2. In dark mode, the primary color usually needs to be lightened. The same blue #2563eb has enough contrast on a white background, but on a black background it looks dim and muddy, so you often need to shift it lighter (e.g. #3b82f6).
  3. Hierarchy comes from "slightly lightening" rather than shadows. In dark mode, the card background is usually a little lighter than the page background (e.g. bg #0a0a0a, surface #161616), forming a natural layering that's more harmonious than simply piling on shadows.
  4. Don't use pure white #ffffff for body text. Pure white text on a dark background is too high-contrast and glaring for long-form reading; #ededed ~ #e0e0e0 is recommended.

A guiding principle: dark-mode design isn't "color inversion", it's "re-coloring". Every color needs to be re-evaluated for how it performs on a dark background.

5.4 Pairing with Tailwind: One Line of Config to Hook In

If you use Tailwind (v3+), just hook the tokens in; the dark: variant will automatically apply the values under .dark:

js
// tailwind.config.js
module.exports = {
  darkMode: 'class',   // Key: use the class strategy, aligned with our inline script
  theme: {
    extend: {
      colors: {
        bg: 'var(--color-bg)',
        surface: 'var(--color-surface)',
        text: 'var(--color-text)',
        border: 'var(--color-border)',
      },
    },
  },
};
tsx
<div className="bg-surface text-text border border-border">
  Card content
</div>

This way you rarely even need to write the dark: prefix—because the colors themselves are variables, they switch automatically with the class.


6. Image and Icon Adaptation: Visual Assets Under Dark Mode

Text and background are handled, but there's another often-forgotten battleground—images, icons, and logos.

6.1 Content Images: Lowering Brightness in Dark Mode

Content images like product screenshots and banners are often "glaringly bright" on dark pages. The simplest fallback is to wrap <img> in a brightness reduction:

css
.dark img:not([data-no-dim]) {
  filter: brightness(0.85);
}

data-no-dim is an escape hatch—some images (like already-dark design mockups) don't need further dimming, and you exempt them with <img data-no-dim ... />.

Note that filter creates a compositing layer; applying filter to many images at once eats GPU memory. In production, prefer swapping the source with <picture>, with filter as a fallback.

6.2 Different Image Sources: Switching with <picture> + media

Logos, hero images, and transparent-background PNGs often need one version each for light/dark. The <picture> element is the most elegant solution:

html
<picture>
  <source srcset="/logo-dark.svg" media="(prefers-color-scheme: dark)" />
  <img src="/logo-light.svg" alt="Logo" />
</picture>

But note—<source>'s media only recognizes the system preference, not the .dark class you added manually! This means under the three-state model, when the user manually switches to dark (but the system is light), <picture> won't swap the image.

There are two engineering solutions:

  1. Listen for theme changes in JS and dynamically swap src (most reliable):
    tsx
    const logo = isDark ? '/logo-dark.svg' : '/logo-light.svg';
    return <img src={logo} alt="Logo" />;
    
  2. Render both sets of images and control visibility with CSS:
    html
    <img className="block dark:hidden" src="/logo-light.svg" alt="Logo" />
    <img className="hidden dark:block" src="/logo-dark.svg" alt="Logo" />
    
    Simple and crude, but it adds an extra resource download—use with caution in performance-sensitive scenarios.

6.3 Monochrome Icons: currentColor and the invert filter

Vector icons (SVG) are easier to handle. The best practice is to have the SVG use fill="currentColor" / stroke="currentColor", so the color automatically follows the text color, with zero cost for light/dark switching:

html
<svg fill="currentColor" viewBox="0 0 24 24">
  <path d="..." />
</svg>

For third-party icons whose source you can't change and that only have a light version, you can temporarily use invert:

css
/* In dark mode, invert light icons—use only as a last resort, colors will distort */
.dark .icon-invert {
  filter: invert(1) hue-rotate(180deg);
}

invert(1) is a crude color inversion, and hue-rotate(180deg) compensates the hue (so the non-black-and-white parts don't turn weird colors). This is only an emergency solution; if you can change to currentColor, don't use invert.

6.4 prefers-reduced-transparency and Accessibility

Let me mention an easily overlooked accessibility dimension. Some users (Windows has a "disable transparency" setting) will enable prefers-reduced-transparency; for these users you should avoid stacking semi-transparent backgrounds. There's also prefers-contrast: more (high contrast), where you can add another set of .high-contrast variables beyond the three themes, further accommodating users with visual impairments. Take dark mode deep enough and it inevitably leads to the bigger proposition of "themability".


7. Next.js + next-themes: Don't Reinvent the Wheel

At this point you can build dark mode from scratch. But in a Next.js project, using next-themes directly is strongly recommended—it packages up every pitfall you've seen so far (three-state model, FOUC, hydration adaptation, SSR compatibility, system listening).

7.1 Installation and Provider

bash
npm install next-themes

In the App Router, create a Client Provider to wrap the whole tree:

tsx
// app/providers.tsx
'use client';

import { ThemeProvider } from 'next-themes';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider
      attribute="class"          // Use the class strategy, aligned with what's described above
      defaultTheme="system"      // Default to following the system
      enableSystem               // Enable system preference detection
      disableTransitionOnChange  // Disable transition animations on switch (avoid each element flashing)
    >
      {children}
    </ThemeProvider>
  );
}
tsx
// app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh" suppressHydrationWarning>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Don't miss two details:

  • Add suppressHydrationWarning on <html>. Because next-themes changes <html>'s class before hydration, React will think someone secretly modified the DOM and sound the alarm—adding this prop tells React "I know about this, leave it alone".
  • disableTransitionOnChange is very useful—when switching themes, if you don't disable transitions, every element using transition will animate on its own, visually very cluttered. This option temporarily disables all transitions at the moment of the switch.

7.2 The Toggle Component

tsx
// components/ThemeToggle.tsx
'use client';

import { useTheme } from 'next-themes';
import { useEffect, useState } from 'react';

export function ThemeToggle() {
  const { theme, setTheme, resolvedTheme } = useTheme();
  const [mounted, setMounted] = useState(false);

  useEffect(() => setMounted(true), []);

  if (!mounted) {
    // Prevent hydration mismatch, render a placeholder before mounting
    return <div className="w-9 h-9" />;
  }

  const isDark = resolvedTheme === 'dark';

  return (
    <button
      onClick={() => setTheme(isDark ? 'light' : 'dark')}
      aria-label="Toggle theme"
    >
      {isDark ? '☀️' : '🌙'}
    </button>
  );
}

Note that the theme returned by useTheme() is the user's stored intent (which may be 'system'), while resolvedTheme is the actually applied theme (always 'light' or 'dark'). To decide which icon to show currently, use resolvedTheme.

All the logic from the previous six sections—three-state resolve, localStorage persistence, anti-FOUC inline script injection, system listening, SSR hydration—would take at least 100 lines to hand-write, with every edge condition being a pitfall. next-themes packages all of this up, and:

  • Automatically injects a synchronous blocking script into <head> (solving FOUC, exactly the approach in section four).
  • Compatible with React 18/19, App Router, and Server Components.
  • Cross-tab synchronization (switch the theme in one tab and the others follow automatically).
  • Supports custom storage keys, custom class names, and custom theme lists.

A judgment criterion: if your theme logic exceeds 30 lines of hand-written code, or you start thinking "what about FOUC?"—stop and install next-themes. That's the entire reason it exists.

Of course, understanding what it does internally (the previous seven sections are essentially its source logic) is still important—when something goes wrong you can locate it, and when you need to customize you can change it. Using a tool doesn't mean you don't need to understand the principles.


8. Testing Checklist: Four Scenarios You Must Pass

Writing dark mode isn't the end—you must pass these four test scenarios; miss any one and you could ship a disaster.

8.1 Scenario One: Real-Time Following of System Switching

  • Set the system to dark and open the site → should be dark directly (no white flash).
  • While the site is open, switch the system theme → the site should follow in real time, without needing a refresh.
  • This tests whether the change listener for prefers-color-scheme is working.

8.2 Scenario Two: Manual-Switch Persistence

  • The user manually switches to dark on the site.
  • Close the browser and reopen the site → should remain dark.
  • This tests localStorage reading and writing.
  • Extra: switch the system to light and reopen the site → should still be dark (showing the manual choice takes priority over the system, with the three states correct).

8.3 Scenario Three: FOUC Testing (Most Easily Missed)

  • Set the system to dark, clear the cache, and refresh the page on a slow network (set Slow 3G in Chrome DevTools).
  • Carefully observe the first frame of the first screen—it should be dark, with no white flash at all.
  • This tests whether the inline script actually takes effect before the first frame.
  • Tip: open DevTools' Performance panel, record a load, and check the background color at the first-frame paint.

8.4 Scenario Four: Cross-Browser and Private Mode

  • Safari (macOS + iOS): the most standards-compliant support for prefers-color-scheme; focus on testing that iOS's "smart invert" doesn't interfere with your images.
  • Chrome/Firefox: standard.
  • Private mode (Safari Private, Chrome Incognito): localStorage may be restricted; confirm the try/catch fallback returns to the system preference without throwing.
  • With screen readers: confirm the toggle button has an aria-label, and that aria-pressed/aria-label reflects the current state.

8.5 A Small Tip for Automated Testing

Simulating the system theme in E2E tests (Playwright/Cypress) relies on emulating the media feature, not on changing the system setting:

ts
// Playwright
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/');
await expect(page.locator('html')).toHaveClass(/dark/);

page.emulateMedia is the right way to blindly change prefers-color-scheme, and it runs stably in CI.


Summary

Looking back, the core of dark mode really comes down to one sentence: make "deciding the theme" happen before the first frame is painted. Almost all the engineering complexity revolves around this one sentence.

Distilling a few key points:

  1. The three-state model is the foundation—manage user intent with 'light' | 'dark' | 'system', never a boolean. Store intent in localStorage, not the result.
  2. The class strategy is the community consensus—add .dark on <html>, pair it with CSS variables, and fully decouple components from the theme.
  3. Anti-FOUC is mandatory—inject a synchronous inline script in <head> and set the class before the first frame. Skip this in an SSR framework and you will flash.
  4. Semantic tokens determine the ceiling—name colors "by purpose" rather than "by hue", have components know only tokens, and adding a new theme is just adding a set of variables.
  5. Next.js projects should go straight to next-themes—it packages up all of the above, but you need to understand what it does internally.

Key takeaway: the essence of dark mode isn't swapping a color palette, but liberating "theme" from hardcoding into a first-class citizen—once you get it right, the maintainability of your entire UI system takes a step up.

Related

Related Content