Dark mode and theming — the real implementation strategies

A real dark-mode implementation needs to answer three separate questions — what the system prefers, what the user explicitly chose, and how that choice gets applied without a flash of the wrong theme — and conflating them is where most implementations go wrong.

Advanced

3 min read

Question 1: detecting the system's preference, automatically

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #111827;
    --text: #f3f4f6;
  }
}

prefers-color-scheme reads the operating system's own light/dark setting directly in CSS, with no JavaScript required — this alone is enough for a page that simply wants to follow the system automatically, with no in-app toggle at all. This is the baseline every implementation should have regardless of what else gets added: even a page with a manual toggle should default to respecting the system preference before the user has ever expressed an explicit choice of their own.

Question 2: letting the user override the system with an explicit choice

// A toggle sets an explicit attribute, which takes priority over the system default
document.documentElement.setAttribute("data-theme", "dark"); // or "light"
localStorage.setItem("theme", "dark"); // persisted, so it survives a reload
/* System preference sets the default... */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --bg: #111827; --text: #f3f4f6; }
}
/* ...but an EXPLICIT choice always wins, in either direction */
:root[data-theme="dark"] { --bg: #111827; --text: #f3f4f6; }
:root[data-theme="light"] { --bg: #ffffff; --text: #111827; }

The :not([data-theme="light"]) guard on the media-query block is the detail that makes this actually correct: without it, a user who explicitly chose light mode would still get the dark-mode media query's styles applied underneath (since the OS is still reporting dark), fighting for priority against their own explicit choice. The full, correct pattern needs three rule sets — system-preference-driven, explicit-dark-override, explicit-light-override — each guarding correctly against the other, which is a genuinely easy detail to get subtly wrong by only handling two of the three states.

Question 3: avoiding a flash of the wrong theme on page load

<!-- Inline script in <head>, BEFORE any stylesheet or app JS runs —
     sets the attribute synchronously, before the browser paints anything -->
<script>
  const saved = localStorage.getItem("theme");
  if (saved) document.documentElement.setAttribute("data-theme", saved);
</script>

If the theme attribute is set by a script that runs after the page has already started rendering (a normal <script> at the bottom of the page, or a framework's client-side hydration), the page briefly renders in the wrong theme first, then visibly flips — a jarring, well-known bug usually called "FOUC" (flash of unstyled/incorrect content) in this context. The fix is running a tiny, synchronous inline script in <head>, before the browser has painted anything, specifically so the correct data-theme attribute is already set by the time any CSS actually gets applied.

Theming with tokens, not hardcoded colors scattered through components

/* Every component reads from these tokens, never a literal color value directly */
:root { --surface: #ffffff; --on-surface: #111827; --accent: #2563eb; }
.card { background: var(--surface); color: var(--on-surface); }
.button { background: var(--accent); }

Building every component against a small set of semantic custom-property tokens (--surface, --accent, rather than hardcoded hex values scattered through every component's own CSS) means the entire theme genuinely follows from redefining those tokens once, in one place — no component needs its own dark-mode-specific override, because it was never hardcoded to a specific color in the first place. This is the direct practical payoff of the custom-properties lesson's "resolved live, by the browser" behavior, applied specifically to theming.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What does the `:not([data-theme="light"])` guard on a dark-mode media query block actually prevent?

2. Why does a theme toggle need an inline script in `<head>`, run before any other JS, to avoid a flash of the wrong theme?

3. What's the practical payoff of building components against semantic custom-property tokens instead of hardcoded colors?