StackPractices
intermediate By Mathias Paulenko

Dark Mode with prefers-color-scheme and CSS Variables

How to implement dark mode using CSS prefers-color-scheme media query, CSS custom properties, and manual toggle with localStorage persistence.

Topics: frontend

Overview

The prefers-color-scheme CSS media query detects whether the user prefers light or dark mode at the OS level. Combined with CSS custom properties, you can switch all colors, borders, and shadows by overriding a few variables. A manual toggle with localStorage lets users override the system preference, and a small inline script prevents the flash of incorrect theme on page load.

The whole recipe boils down to three moving parts: semantic color tokens (the variables), a way to pick which token set is active (media query, data-theme attribute, or both), and a persistence layer (localStorage) that remembers an explicit user choice. Get those right and the rest is polish — icon swaps, image variants, Tailwind config.

When to Use

  • Any website that should respect the user’s OS dark mode setting
  • Applications where users spend extended time and eye strain matters
  • Sites with code blocks — dark mode reduces glare for developers
  • Content-heavy sites where dark mode improves readability in low-light environments

When NOT to Use

  • Brand-mandated color schemes that must not change
  • Print stylesheets — dark mode doesn’t apply to printed pages
  • Legacy browsers without prefers-color-scheme support (IE11, older Safari)

Solution

CSS-only dark mode with prefers-color-scheme

:root {
  --color-bg: #ffffff;
  --color-surface: #f9fafb;
  --color-text: #111827;
  --color-text-muted: #6b7280;
  --color-border: #e5e7eb;
  --color-accent: #3b82f6;
  --color-accent-hover: #2563eb;
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #0f172a;
    --color-surface: #1e293b;
    --color-text: #e2e8f0;
    --color-text-muted: #94a3b8;
    --color-border: #334155;
    --color-accent: #3b82f6;
    --color-accent-hover: #60a5fa;
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
  }
}

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

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

a {
  color: var(--color-accent);
}

a:hover {
  color: var(--color-accent-hover);
}

Manual toggle with data-theme attribute

:root,
[data-theme="light"] {
  --color-bg: #ffffff;
  --color-surface: #f9fafb;
  --color-text: #111827;
  --color-text-muted: #6b7280;
  --color-border: #e5e7eb;
  --color-accent: #3b82f6;
}

[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-surface: #1e293b;
  --color-text: #e2e8f0;
  --color-text-muted: #94a3b8;
  --color-border: #334155;
  --color-accent: #60a5fa;
}
function setTheme(theme) {
  document.documentElement.setAttribute("data-theme", theme);
  localStorage.setItem("theme", theme);
}

function toggleTheme() {
  const current = document.documentElement.getAttribute("data-theme");
  setTheme(current === "dark" ? "light" : "dark");
}

// Initialize from localStorage or system preference
const saved = localStorage.getItem("theme");
if (saved) {
  setTheme(saved);
} else {
  const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
  setTheme(prefersDark ? "dark" : "light");
}

Preventing flash of incorrect theme (FOUC)

Place this script in the <head> before CSS loads:

<head>
  <script>
    (function () {
      const saved = localStorage.getItem("theme");
      const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
      const theme = saved || (prefersDark ? "dark" : "light");
      document.documentElement.setAttribute("data-theme", theme);
    })();
  </script>
  <link rel="stylesheet" href="/styles.css" />
</head>

Toggle button component

<button id="theme-toggle" aria-label="Toggle theme">
  <svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
    <circle cx="12" cy="12" r="5" />
    <line x1="12" y1="1" x2="12" y2="3" />
    <line x1="12" y1="21" x2="12" y2="23" />
    <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
    <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
  </svg>
  <svg class="moon-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor">
    <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
  </svg>
</button>

<script>
  const toggle = document.getElementById("theme-toggle");
  toggle.addEventListener("click", toggleTheme);

  // Update icon visibility
  function updateIcon() {
    const theme = document.documentElement.getAttribute("data-theme");
    toggle.classList.toggle("dark", theme === "dark");
  }
  updateIcon();
  toggle.addEventListener("click", updateIcon);
</script>

<style>
  .sun-icon { display: block; }
  .moon-icon { display: none; }
  #theme-toggle.dark .sun-icon { display: none; }
  #theme-toggle.dark .moon-icon { display: block; }
</style>

Listening to system preference changes

window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => {
  const saved = localStorage.getItem("theme");
  if (!saved) {
    document.documentElement.setAttribute("data-theme", e.matches ? "dark" : "light");
  }
});

Dark mode with Tailwind CSS v4

@theme {
  --color-bg: #ffffff;
  --color-surface: #f9fafb;
  --color-text: #111827;
}

@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-surface: #1e293b;
  --color-text: #e2e8f0;
}

Use the dark: variant in components:

<div class="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100">
  Content
</div>

Dark mode with images

:root {
  --logo-url: url("/images/logo-light.png");
}

[data-theme="dark"] {
  --logo-url: url("/images/logo-dark.png");
}

.logo {
  background-image: var(--logo-url);
  background-size: contain;
  background-repeat: no-repeat;
}

Dark mode with code syntax highlighting

:root {
  --code-bg: #f8f8f8;
  --code-text: #333;
  --code-keyword: #c678dd;
  --code-string: #98c379;
  --code-comment: #7c7c7c;
}

[data-theme="dark"] {
  --code-bg: #282c34;
  --code-text: #abb2bf;
  --code-keyword: #c678dd;
  --code-string: #98c379;
  --code-comment: #5c6370;
}

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

.token.keyword { color: var(--code-keyword); }
.token.string { color: var(--code-string); }
.token.comment { color: var(--code-comment); }

How It Works

The mechanism is CSS cascade plus custom property re-resolution. Variables declared on :root or [data-theme] inherit down to every element, so when you flip data-theme on <html>, the browser re-resolves every var(--color-*) reference and repaints — no per-component code needed. Compare that with the .dark .card { ... } approach, where every colored rule gets duplicated under the dark selector — with tokens, the whole page restyles from a single attribute change at the top. If your tokens aren’t organized yet, the Design Tokens with CSS Custom Properties recipe covers the naming and layering.

Precedence matters. The media query version only responds to the OS; a user toggle can’t override it because @media (prefers-color-scheme: dark) styles apply regardless of what localStorage says. That’s why the toggle variant moves the dark values out of the media query and behind [data-theme="dark"] — the attribute becomes the single source of truth, and JavaScript decides whether it comes from saved preference or the OS. If you want both worlds, set the attribute on first load from prefers-color-scheme (the anti-FOUC script does exactly this) and let the toggle take over afterward.

The inline <head> script exists for timing. CSSOM construction happens before the body parses; a script that runs after the stylesheet has already painted the wrong theme will flash it for a frame. Running inline before the <link> guarantees the attribute is set on first paint — it’s about 200 bytes of insurance.

For a fancier toggle, document.startViewTransition() lets the browser snapshot both states and animate between them — the same “circle expanding from the button” effect native apps use. Wrap the data-theme change in the callback; graceful degradation is free since browsers without support just swap instantly.

Variants

Three-way toggle (light/dark/system)

const THEME_KEY = "theme";

function getStoredTheme() {
  return localStorage.getItem(THEME_KEY) || "system";
}

function getResolvedTheme() {
  const stored = getStoredTheme();
  if (stored === "system") {
    return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
  }
  return stored;
}

function setTheme(theme) {
  localStorage.setItem(THEME_KEY, theme);
  document.documentElement.setAttribute("data-theme", getResolvedTheme());
}

setTheme(getStoredTheme());

window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
  if (getStoredTheme() === "system") {
    document.documentElement.setAttribute("data-theme", getResolvedTheme());
  }
});

Using CSS color-scheme property

:root {
  color-scheme: light;
}

[data-theme="dark"] {
  color-scheme: dark;
}

That one line makes scrollbars and form controls dress for the occasion too.

Browser chrome with theme-color

The browser UI around your page — the mobile URL bar, the title-bar tint on some platforms — can match the theme too:

<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">

One caveat: theme-color listens to the media query, not to data-theme. If the user picks dark manually while their OS is light, the page goes dark but the chrome stays light. You can update the tag from JavaScript when the toggle runs — document.querySelector('meta[name="theme-color"]').content = theme === "dark" ? "#0f172a" : "#ffffff".

Accessibility and Testing

Dark mode introduces a new axis of contrast bugs. A gray that passes on white (--color-text-muted: #6b7280 → 4.8:1) fails on dark backgrounds (#94a3b8 on #0f172a → 7.3:1, fine — but check each pair). WCAG 2.2 requires 4.5:1 for body text and 3:1 for large text and UI components; run a contrast checker against both palettes, not just the light one.

Two related media queries worth knowing: prefers-contrast: more lets you bump contrast for users who ask for it, and prefers-reduced-motion should gate any theme-transition animation. For the broader accessibility checklist, see the WCAG Complete Guide.

@media (prefers-reduced-motion: no-preference) {
  html.theme-transition, html.theme-transition * {
    transition: background-color 0.2s ease, color 0.2s ease !important;
  }
}

Add the theme-transition class briefly around a toggle click and remove it — transitioning every element all the time causes a repaint storm.

Testing both themes by hand gets old fast. Chrome DevTools covers it (Rendering tab → “Emulate CSS media feature prefers-color-scheme”), and Playwright can emulate per test:

test('dark theme applies', async ({ page }) => {
  await page.emulateMedia({ colorScheme: 'dark' });
  await page.goto('/');
  const bg = await page.evaluate(
    () => getComputedStyle(document.body).backgroundColor
  );
  expect(bg).toBe('rgb(15, 23, 42)'); // #0f172a
});

Assert on the resolved color, not on the attribute — it catches both the media-query path and the token definitions.

Best Practices

  • Use semantic token names (--color-bg, --color-text) — switching themes means overriding a few variables, not rewriting all CSS
  • Place the anti-FOUC script inline in <head> before CSS — it must run before the first paint
  • Respect system preference by default — only override when the user explicitly toggles
  • Listen to prefers-color-scheme changes — if the user hasn’t set a manual preference, follow the OS
  • Test both themes — some color combinations look fine in light mode but have poor contrast in dark mode
  • Use color-scheme: light dark — this styles native UI elements (scrollbars, form controls) correctly
  • Reduce shadow intensity in dark mode — shadows are less visible on dark backgrounds and can look harsh

Common Mistakes

  • Flash of light theme (FOUC): not setting data-theme before CSS loads. The inline head script prevents this.
  • Hardcoded colors in components: background: #fff ignores the token layer entirely — that card stays white while the rest of the page goes dark.
  • Not persisting user choice: without localStorage, the user’s toggle resets on page reload.
  • Ignoring system preference: defaulting to light when the user’s OS is set to dark — check prefers-color-scheme first.
  • Poor contrast in dark mode: dark gray on a dark background disappears. Run the numbers through a contrast checker for both palettes.
  • Transitioning on load: slap * { transition: all } on the page and the initial theme application animates too — the site fades in like a slideshow on every visit. Scope transitions to the toggle moment.
  • Reading localStorage on every check: localStorage access can throw in private browsing edge cases and blocks the main thread — wrap it in try/catch if you support strict privacy modes.
  • Forgetting color-scheme: without it, scrollbars and form controls stay light inside your dark UI — one property fixes it.
  • SSR mismatches: frameworks that render on the server can’t see localStorage — the inline head script must run client-side before hydration or React/Vue will warn about attribute mismatches.

Still seeing the flash? Debug in this order: confirm the inline script is actually the first thing in <head> (a blocking <link> or @import above it repaints before it runs); check data-theme is set — localStorage throws in some private modes, so wrap the read; and verify you’re not mixing paths — CSS behind @media (prefers-color-scheme: dark) ignores the attribute, so pick the media-query variant or the data-theme variant, not both for the same properties.

Further Reading

Frequently Asked Questions

What is prefers-color-scheme?

It's the media query that reads your OS theme setting — prefers-color-scheme: dark is true whenever the OS is in dark mode, and the browser exposes it to every page.

How do I prevent the flash of incorrect theme?

Put a tiny inline script in <head> that stamps data-theme before the stylesheet loads — the right theme then shows up on the very first paint.

Should I use prefers-color-scheme or a manual toggle?

Both. Use prefers-color-scheme as the default, then let the user override with a manual toggle. Store their choice in localStorage and respect it on subsequent visits.

Does prefers-color-scheme work in all browsers?

Chrome has supported it since 76, Firefox since 67, Safari since 12.1, Edge since 79 — that's 96%+ of users in 2026. Anything older simply gets light mode.

How do I handle images in dark mode?

Use CSS custom properties for image URLs, or apply filter: invert(1) for simple icons. For photos, consider reducing brightness slightly in dark mode:

[data-theme="dark"] img {
  filter: brightness(0.9);
}
Can I animate the theme switch?

Yes, but scope it — apply a transition class only around the toggle click and remove it after, and respect prefers-reduced-motion. A global transition: all animates the initial page load too, which produces a slow-motion paint every visit.