DEV Community

Cover image for Dark Mode Isn't a Feature. It's a Promise. Here's How to Keep It.
Bishoy Bishai
Bishoy Bishai

Posted on • Edited on • Originally published at bishoy-bishai.github.io

Dark Mode Isn't a Feature. It's a Promise. Here's How to Keep It.

Every flash of white on a dark-mode screen is your app saying: "I wasn't listening." Let's fix that.

"Good design is invisible."
— Dieter Rams


There's a specific kind of frustration that lives in the gap between what a user has configured and what your app delivers.

The user has been running their entire OS in dark mode for two years. Their phone, their terminal, their code editor, their email client — everything dark. Then they open your web app. The screen explodes with white. Their eyes adjust. They find the toggle buried in settings somewhere. They switch to dark mode. They close the tab.

Tomorrow, they open the app again. White screen. Again.

That's not a UX bug. That's a broken promise. The user told your application something — "I prefer dark" — and your application ignored it, forgot it, or never listened in the first place.

I've been that user. I've also been the developer who shipped that app. Both experiences are instructive, but only one of them is embarrassing in retrospect.

At one of my ex-company — the mental health platform where I built the first mobile app — UI decisions carried weight I hadn't fully considered before. When the product is something people use during difficult moments, at night, often in bed, a blinding white flash on load isn't just annoying. It's the app announcing its indifference to the person on the other side of the screen. That's when I stopped treating dark mode as a checkbox feature and started treating it as the first test of whether the UI actually listens.

What you'll be able to DO after reading this:

  • Build a dark mode implementation that respects the OS preference without requiring user action
  • Eliminate FOUC (Flash of Unstyled Content) completely — including why it happens and the exact script that kills it
  • Build a persistence layer that remembers the user's explicit choice without fighting against their system preferences
  • Understand the three trust layers of a correct dark mode implementation and which one most apps get wrong
  • Walk away with production-ready code you can drop into any React app today

This is not a "here's the toggle" tutorial. Those exist. This is the thinking behind the implementation — the part that makes the difference between dark mode that works and dark mode that listens.


The Three Trust Layers — This Is the Mental Model

Before a single line of code, understand this framework. Every dark mode implementation lives somewhere on a spectrum of how much it trusts the user. Most implementations get Layer 1 wrong, ignore Layer 2 entirely, and accidentally break Layer 3.

Layer 1 — Trust the System

Your user has already told their operating system their preference. That preference is a signal. Ignoring it on first load is the equivalent of asking someone "what language do you speak?" when they've already been talking to you for five minutes.

The prefers-color-scheme media query gives you this signal for free. The question is whether you're listening.

Layer 2 — Trust the Session

The user has explicitly toggled your app's theme. That's an override — they're saying "I know what my system says, but for this app, I want this." This explicit choice must survive page reloads, browser restarts, and new tabs. localStorage is your memory here.

Layer 3 — Trust the Render

This is the one most apps fail. Even if you've correctly read the system preference and correctly stored the user's choice — if your JavaScript runs after your CSS paints, the user sees the wrong theme for 100-300 milliseconds before it corrects itself. That flash is your app saying "I knew the right answer, I just told you the wrong thing first."

A correct implementation satisfies all three layers, in this order of priority:

Layer 2 (explicit user choice) > Layer 1 (system preference) > default (light)
Enter fullscreen mode Exit fullscreen mode

And Layer 3 — the render trust — is not optional. It's the silent contract. The user never sees it when it works. They only notice when it breaks.


The Foundation: CSS Variables as Theme Tokens

Here's the thing about dark mode implementations that use JavaScript to swap classes or manipulate styles directly: they're fighting the browser. CSS already knows how to cascade values. CSS already knows how to respond to selectors. Let it do its job.

CSS Custom Properties — CSS Variables — are the right primitive for this problem. Not because they're modern or elegant (though they are both), but because they let you express the relationship between a theme decision and a visual result in one place, and then change the decision without touching the visual rules.

/* globals.css */

/* Layer 1: System preference — the default, no JavaScript required */
:root {
  --color-bg-primary: #ffffff;
  --color-bg-secondary: #f5f5f5;
  --color-bg-card: #ffffff;
  --color-text-primary: #1a1a1a;
  --color-text-secondary: #666666;
  --color-text-muted: #999999;
  --color-accent: #0070f3;
  --color-accent-hover: #0051cc;
  --color-border: #e5e5e5;
  --color-shadow: rgba(0, 0, 0, 0.08);

  /* Transitions — applied once here, inherited everywhere */
  --transition-theme: background-color 0.25s ease, color 0.25s ease, border-color 0.25s ease;
}

/* System prefers dark — no JavaScript, no React, pure CSS */
@media (prefers-color-scheme: dark) {
  :root {
    --color-bg-primary: #0d0d0d;
    --color-bg-secondary: #1a1a1a;
    --color-bg-card: #1e1e1e;
    --color-text-primary: #ededed;
    --color-text-secondary: #a0a0a0;
    --color-text-muted: #666666;
    --color-accent: #3b82f6;
    --color-accent-hover: #60a5fa;
    --color-border: #2a2a2a;
    --color-shadow: rgba(0, 0, 0, 0.4);
    /* Note: shadows in dark mode need MORE opacity, not less.
       Light on dark needs a stronger shadow to maintain depth perception. */
  }
}

/* Layer 2: Explicit user choice via data-theme attribute.
   These override the @media query above.
   If the user says "light" on a dark-mode system, we listen. */
[data-theme='light'] {
  --color-bg-primary: #ffffff;
  --color-bg-secondary: #f5f5f5;
  --color-bg-card: #ffffff;
  --color-text-primary: #1a1a1a;
  --color-text-secondary: #666666;
  --color-text-muted: #999999;
  --color-accent: #0070f3;
  --color-accent-hover: #0051cc;
  --color-border: #e5e5e5;
  --color-shadow: rgba(0, 0, 0, 0.08);
}

[data-theme='dark'] {
  --color-bg-primary: #0d0d0d;
  --color-bg-secondary: #1a1a1a;
  --color-bg-card: #1e1e1e;
  --color-text-primary: #ededed;
  --color-text-secondary: #a0a0a0;
  --color-text-muted: #666666;
  --color-accent: #3b82f6;
  --color-accent-hover: #60a5fa;
  --color-border: #2a2a2a;
  --color-shadow: rgba(0, 0, 0, 0.4);
}

/* Component styles use variables — never hardcoded values.
   This is the rule. No exceptions. */
body {
  background-color: var(--color-bg-primary);
  color: var(--color-text-primary);
  transition: var(--transition-theme);
}
Enter fullscreen mode Exit fullscreen mode

Notice the naming: --color-text-primary, not --dark-grey or --almost-white. This is semantic naming — the variable describes its role, not its current value. When you name it --dark-grey, you're forcing yourself to use a confusingly-named variable in light mode. When you name it --color-text-primary, it means the same thing in both themes: "this is what primary text looks like."

I've inherited codebases where someone named variables --light-background and then used it in the dark theme because it was "the right shade." Don't be that person. Name the role. Let the value change.


Layer 3 First: Killing the FOUC Before React Loads

I'm going to explain Layer 3 before building the React implementation — because if you don't understand why the flash happens, you'll implement the React part correctly and still ship the flash.

Here's the sequence of events when a user loads your React app:

  1. Browser requests the HTML file
  2. Browser receives HTML — at this point, no CSS, no JavaScript has run
  3. Browser parses HTML, discovers <link> for CSS and <script> for JS
  4. Browser downloads and applies CSS — page renders with default theme values (no data-theme attribute yet)
  5. Browser downloads JavaScript bundle
  6. React initializes
  7. ThemeProvider runs, reads localStorage, calls document.documentElement.setAttribute('data-theme', ...)
  8. CSS variables update, theme corrects

Steps 4 through 7 can take anywhere from 50ms to 500ms depending on bundle size and network. In that window, the user sees the default theme — light — even if their localStorage says dark and their system says dark.

That's the flash. It's not a React bug. It's a timing problem. JavaScript runs after CSS renders.

The only solution is to run the theme-detection code before JavaScript loads. Not in React. Not in a hook. In a synchronous <script> tag in the <head> — the kind that blocks rendering until it executes.

<!-- public/index.html — inside <head>, BEFORE any stylesheet links -->
<!-- Why before stylesheets? Because we want data-theme set before CSS cascades. -->
<script>
  (function() {
    // This runs synchronously, before the browser renders anything.
    // No React. No hooks. Just vanilla JavaScript at the earliest possible moment.

    function getInitialTheme() {
      // Priority 1: explicit user choice stored in localStorage
      try {
        // Wrap in try/catch — localStorage can throw in some 
        // private browsing modes or with certain security policies
        var storedTheme = localStorage.getItem('app-theme');
        if (storedTheme === 'dark' || storedTheme === 'light') {
          return storedTheme;
        }
      } catch (e) {
        // localStorage unavailable — fall through to system preference
      }

      // Priority 2: system preference
      if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
        return 'dark';
      }

      // Priority 3: default
      return 'light';
    }

    // Set the attribute immediately — before CSS cascades, before React runs.
    // When the browser renders the first frame, data-theme is already correct.
    document.documentElement.setAttribute('data-theme', getInitialTheme());
  })();
</script>
Enter fullscreen mode Exit fullscreen mode

This script is intentionally minimal. No imports. No dependencies. No async operations. It runs in under a millisecond, sets the data-theme attribute, and gets out of the way. By the time the browser renders the first frame of your page, the attribute is already set, and the CSS variables already have the correct values.

For Next.js users: the public/index.html approach doesn't work in Next.js. You need _document.tsx:

// pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';

// This is a string — intentional.
// We're injecting raw JavaScript into the document.
// It needs to be a script that runs before React hydration.
const themeScript = `
  (function() {
    function getInitialTheme() {
      try {
        var stored = localStorage.getItem('app-theme');
        if (stored === 'dark' || stored === 'light') return stored;
      } catch (e) {}
      return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
    }
    document.documentElement.setAttribute('data-theme', getInitialTheme());
  })();
`;

export default function Document() {
  return (
    <Html>
      <Head>
        {/* dangerouslySetInnerHTML is correct here — we're injecting a known, 
            controlled script. The "dangerous" part is using it with user input.
            This is a static string we wrote. */}
        <script dangerouslySetInnerHTML={{ __html: themeScript }} />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Test this: disable JavaScript in your browser. Open your app. The correct theme should still be applied — because the CSS @media (prefers-color-scheme: dark) handles the system preference without any JavaScript at all. Enable JavaScript. The data-theme attribute handles user overrides. This is a graceful degradation stack, not a JavaScript dependency.


The React Layer: Context, Hook, and State Management

Now we build the React side. The job of the React layer is to:

  1. Read the initial theme (which the inline script already set on <html>)
  2. Provide a toggle that updates both the DOM attribute and localStorage
  3. Listen for OS-level changes and respond — but only if the user hasn't made an explicit choice
// src/contexts/ThemeContext.tsx

import React, {
  createContext,
  useContext,
  useState,
  useEffect,
  useCallback,
  ReactNode,
} from 'react';

type Theme = 'light' | 'dark';

interface ThemeContextValue {
  theme: Theme;           // Current active theme
  toggleTheme: () => void; // Flip between light and dark
  setTheme: (t: Theme) => void; // Set explicitly
  isSystemTheme: boolean; // True if no explicit user choice has been made
  clearOverride: () => void; // Remove the explicit choice, defer back to system
}

const ThemeContext = createContext<ThemeContextValue | null>(null);

// The localStorage key — define once, reference everywhere.
// Changing this key means existing user preferences are lost.
const STORAGE_KEY = 'app-theme';

function readStoredTheme(): Theme | null {
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    if (stored === 'light' || stored === 'dark') return stored;
  } catch {
    // localStorage unavailable
  }
  return null;
}

function getSystemTheme(): Theme {
  if (typeof window === 'undefined') return 'light'; // SSR guard
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

function applyTheme(theme: Theme): void {
  // Set the attribute that our CSS variables respond to.
  // This is the single source of visual truth.
  document.documentElement.setAttribute('data-theme', theme);
}

export function ThemeProvider({ children }: { children: ReactNode }) {
  // Initialize from localStorage first, then system preference.
  // This mirrors the inline script exactly — they must agree.
  const [explicitTheme, setExplicitTheme] = useState<Theme | null>(() => {
    return readStoredTheme();
  });

  const [systemTheme, setSystemTheme] = useState<Theme>(() => {
    return getSystemTheme();
  });

  // The active theme: explicit choice wins over system
  const theme: Theme = explicitTheme ?? systemTheme;
  const isSystemTheme = explicitTheme === null;

  // Apply theme to DOM whenever it changes.
  // This handles the React lifecycle — the inline script handles first paint.
  useEffect(() => {
    applyTheme(theme);
  }, [theme]);

  // Listen for OS-level theme changes.
  // Respond ONLY if the user hasn't made an explicit choice.
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

    const handleSystemChange = (e: MediaQueryListEvent) => {
      const newSystemTheme: Theme = e.matches ? 'dark' : 'light';
      setSystemTheme(newSystemTheme);
      // If the user has an explicit override, don't touch it.
      // If they're on system default, follow the system.
      if (readStoredTheme() === null) {
        applyTheme(newSystemTheme);
      }
    };

    mediaQuery.addEventListener('change', handleSystemChange);
    return () => mediaQuery.removeEventListener('change', handleSystemChange);
  }, []);

  const setTheme = useCallback((newTheme: Theme) => {
    try {
      localStorage.setItem(STORAGE_KEY, newTheme);
    } catch {
      // localStorage unavailable — theme will still work for the session
    }
    setExplicitTheme(newTheme);
    // applyTheme is called by the useEffect above when explicitTheme changes
  }, []);

  const toggleTheme = useCallback(() => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  }, [theme, setTheme]);

  // Let the user go back to "follow the system" — remove their explicit choice.
  const clearOverride = useCallback(() => {
    try {
      localStorage.removeItem(STORAGE_KEY);
    } catch {}
    setExplicitTheme(null);
    // Theme will now follow systemTheme
  }, []);

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme, setTheme, isSystemTheme, clearOverride }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme(): ThemeContextValue {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
}
Enter fullscreen mode Exit fullscreen mode

One thing I added that the original article didn't have: isSystemTheme and clearOverride. These matter more than they seem.

If your user is on system dark mode, explicitly switches to light in your app, and then changes their OS to light mode — what happens? With isSystemTheme, you can show a UI hint: "You're using light mode (your system is now also light — sync?)" and give them the clearOverride option to stop managing it manually. Small detail. Enormous UX thoughtfulness.


The Toggle Component — and the Three-State Option

// src/components/ThemeToggle.tsx
'use client'; // Next.js App Router users need this

import { useTheme } from '../contexts/ThemeContext';

export function ThemeToggle() {
  const { theme, toggleTheme, isSystemTheme, clearOverride } = useTheme();

  return (
    <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
      <button
        onClick={toggleTheme}
        // aria-label is not optional. A screen reader user needs to know
        // what the button will DO, not what the current state is.
        // "Switch to dark mode" tells them the action. "Dark mode: on" tells them the state.
        // Both have valid use cases — pick one and be consistent.
        aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
        aria-pressed={theme === 'dark'} // Tells screen readers this is a toggle
        style={{
          background: 'none',
          border: '1px solid var(--color-border)',
          borderRadius: '6px',
          padding: '6px 12px',
          cursor: 'pointer',
          color: 'var(--color-text-primary)',
          // The button itself uses theme variables — it doesn't need special dark mode handling
        }}
      >
        {theme === 'light' ? '🌙 Dark' : '☀️ Light'}
      </button>

      {/* Only show the "use system" option if the user has an explicit override.
          No point offering "follow system" if they're already following it. */}
      {!isSystemTheme && (
        <button
          onClick={clearOverride}
          aria-label="Use system theme preference"
          style={{
            background: 'none',
            border: 'none',
            cursor: 'pointer',
            color: 'var(--color-text-muted)',
            fontSize: '0.8rem',
            padding: '6px',
          }}
        >
          Use system
        </button>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Wire it up in your app root:

// src/App.tsx (or app/layout.tsx in Next.js App Router)
import { ThemeProvider } from './contexts/ThemeContext';
import { ThemeToggle } from './components/ThemeToggle';

export default function App({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider>
      <header>
        <ThemeToggle />
      </header>
      <main>{children}</main>
    </ThemeProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Uncomfortable Truth About Dark Mode

Here's what the tutorials won't tell you, and what took me too long to learn properly:

Dark mode is not just an inverted color palette. And the moment you treat it that way, you've already shipped a subpar experience.

Three things that break in naive dark mode implementations:

1. Shadows

In light mode, shadows are dark on a light background — they create depth. In dark mode, if you keep the same shadow with the same opacity, it becomes nearly invisible because the background is already dark. You need to increase shadow opacity in dark mode, not decrease it. The CSS variable approach handles this automatically if you define --color-shadow separately for each theme — which the code above does.

2. Images and Media

A logo that's dark text on a transparent background — beautiful in light mode, invisible in dark mode. A photograph of a white product on a white background — completely loses its edges in dark mode. These don't fix themselves with CSS variables. They require actual design decisions: separate logo variants, CSS mix-blend-mode for certain images, or the picture element with different srcset per theme.

/* One pattern for images that need to be less harsh in dark mode */
[data-theme='dark'] img:not([data-theme-exempt]) {
  /* Slightly reduce brightness on photos that look blown-out in dark mode */
  filter: brightness(0.9);
}

/* Logo that needs to be inverted */
[data-theme='dark'] .logo-dark-variant {
  display: block;
}
[data-theme='light'] .logo-dark-variant,
:root .logo-dark-variant {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

3. Third-Party Components

You've built perfect dark mode. Then you add a date picker, a chart library, a rich text editor. They bring their own CSS. They don't know about your data-theme attribute. They don't use your CSS variables.

This is where the real work is — and tutorials never talk about it because it's not clean and universal. Each library needs its own theming approach, and sometimes "dark mode support" for a library is a GitHub issue from 2019 with twelve thumbs-up and no resolution. Budget time for this. It will surprise you.


Real Objections

Objection 1: "Why not just use a CSS class instead of data-theme attribute?"

You can. [data-theme='dark'] and .dark-theme both work as CSS selectors. I prefer data-theme for a semantic reason: attributes are meant to carry metadata about an element's state. A class implies visual styling — which is a different concern. data-theme="dark" reads as "this document is in dark theme" which is accurate. .dark-theme reads as "apply dark theme styles to this element" which is also accurate but conflates what it is with how it looks.

In practice: it doesn't matter much. Pick one, be consistent, document it in your CSS architecture notes.

Objection 2: "The inline script in <head> is a render-blocking script. Isn't that bad for performance?"

Yes and no. Render-blocking scripts delay the first paint. But in this case, that's intentional — we want to block rendering until we know the correct theme, because the alternative is rendering the wrong theme and then correcting it (the flash). The script is tiny — about 200 bytes, runs in under a millisecond. The performance cost is negligible. The UX benefit is significant. This is one of the rare cases where render-blocking is the right call.

Objection 3: "What about users who have JavaScript disabled?"

The CSS @media (prefers-color-scheme: dark) query requires zero JavaScript. If the user has JavaScript disabled, the data-theme attribute never gets set, but the media query still applies the correct theme based on system preference. The only thing that doesn't work without JavaScript is the user's explicit override being remembered across sessions — which requires localStorage. Graceful degradation is built in.


📚 Nice to read more about dark mode


✨ Let's keep the conversation going!

If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.

✍️ Read more on my blog: bishoy-bishai.github.io

Let's chat on LinkedIn: linkedin.com/in/bishoybishai

📘 Curious about AI?: You can also check out my book: Surrounded by AI

Top comments (0)