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.
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-schemesupport (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-schemechanges — 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-themebefore CSS loads. The inline head script prevents this. - Hardcoded colors in components:
background: #fffignores 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-schemefirst. - 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
localStorageon every check:localStorageaccess 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
- Design Tokens with CSS Custom Properties — organize the token layer that dark mode swaps.
- WCAG Complete Guide — contrast requirements and the related
prefers-contrastmedia query. - MDN: prefers-color-scheme — media query reference and browser support.
- caniuse: prefers-color-scheme — live coverage data.
- Companion demo — runnable
index.html+styles.css+theme.jsyou can drop into a project as-is.
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.
Related Resources
Design Tokens with CSS Custom Properties
How to build a design token system using CSS custom properties, including color scales, spacing, typography, themes, and responsive scaling.
RecipeContainer Queries for Component Responsiveness
How to use CSS container queries for component-level responsive layouts that adapt to their container size instead of the viewport.
PatternBridge Pattern for Decoupling UI Components from Themes
Separate an abstraction from its implementation so both can vary independently using the Bridge pattern for pluggable UI themes and rendering engines
GuideCSS Modern Layout: Grid, Flexbox, Container Queries
Master modern CSS layout: CSS Grid, Flexbox, container queries, subgrid, logical properties, and responsive design patterns without media queries.
GuideComplete Guide to Web Accessibility: WCAG 2.2 Compliance
Master web accessibility with WCAG 2.2: ARIA roles, keyboard navigation, screen reader support, color contrast, focus management, and accessible forms.