CSS Dark Mode Implementation — System Detection, Toggle, and Flash Prevention
Quick answer
💡The standard approach combines two mechanisms. First, declare light and dark token values using CSS custom properties in :root, then override them inside @media (prefers-color-scheme: dark) to honor the OS preference automatically. Second, add a [data-theme='dark'] selector that applies the same token overrides so a JavaScript toggle can switch themes without changing the OS setting. Add color-scheme: light dark to get native browser UI elements (scrollbars, form controls) to match.
Error symptoms
- ✕
Page flashes white before applying dark mode on load (flash of incorrect theme) - ✕
OS-level dark mode is detected but a user's in-page toggle has no effect - ✕
Custom properties are overriding correctly but browser UI elements like scrollbars stay light - ✕
Dark mode works on first load but resets to light mode after the user navigates to another page - ✕
Images are too bright and hurt contrast in dark mode - ✕
Transition between light and dark modes is jarring — no smooth color fade
Common causes
- •Theme class or data attribute applied after React hydration, causing a visible light flash
- •localStorage preference not read and applied synchronously before first paint
- •prefers-color-scheme media query detects OS preference but no JavaScript toggle is implemented
- •color-scheme property missing, so browser chrome elements stay in light mode
- •Hardcoded hex colors in CSS instead of custom property tokens that switch with the theme
- •Transition property not added to :root so color changes are instant rather than smooth
When it happens
- •After adding dark mode to an existing project where colors were hardcoded rather than tokenized
- •When deploying a Next.js or Astro site where the theme is set client-side after SSR
- •When a user returns to the site with a saved dark mode preference and sees a white flash
- •After adding a third-party component library whose colors are not token-based
- •When a designer asks for dark mode images and the plan is to use CSS filter
Examples and fixes
The minimal implementation that detects OS preference and swaps colors using a custom property token set.
System detection with custom property tokens
❌ Wrong
/* Hardcoded colors — dark mode impossible */
body {
background-color: #ffffff;
color: #1a1a1a;
}
.card {
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.btn-primary {
background: #6366f1;
color: white;
}✅ Fixed
:root {
--bg: #ffffff;
--surface: #f8fafc;
--text: #1a1a1a;
--border: #e2e8f0;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--surface: #1e293b;
--text: #f1f5f9;
--border: #334155;
}
}
body { background: var(--bg); color: var(--text); }
.card { background: var(--surface); border: 1px solid var(--border); }
.btn-primary { background: #6366f1; color: white; }The broken version uses hardcoded hex values that cannot change at runtime. Adding dark mode to this code would require duplicating every rule inside a media query and maintaining two parallel sets of values. The fixed version moves all color values into custom properties and overrides only the token declarations inside the media query. Every rule that consumes the tokens gets dark mode automatically without duplication. The color-scheme: light dark declaration tells the browser to style native UI elements — scrollbars, form inputs, select dropdowns — using the OS-preferred color scheme, which is critical for a cohesive dark mode appearance.
A JavaScript toggle that allows users to override the OS preference and saves their choice to localStorage.
In-page toggle with data-theme attribute
❌ Wrong
<button onclick="document.body.classList.toggle('dark')">Toggle</button>
/* This class cannot reliably override the media query */
.dark body {
background: #0f172a;
color: #f1f5f9;
}✅ Fixed
<!-- Inline script prevents flash of wrong theme -->
<script>
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (saved === 'dark' || (!saved && prefersDark)) {
document.documentElement.setAttribute('data-theme', 'dark');
}
</script>
:root {
--bg: #ffffff; --text: #1a1a1a;
transition: background-color 0.2s, color 0.2s;
color-scheme: light dark;
}
[data-theme='dark'] {
--bg: #0f172a; --text: #f1f5f9;
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
--bg: #0f172a; --text: #f1f5f9;
color-scheme: dark;
}
}The broken version toggles a class on body and relies on a .dark body selector, but specificity and source order make this unreliable when a prefers-color-scheme media query also applies styles. The fixed version uses a data-theme attribute on document.documentElement (the html element), which has the highest DOM scope and works with any descendant selector. The inline script in the head reads localStorage before the first paint, preventing the white flash entirely. The :not([data-theme='light']) guard ensures OS dark mode activates automatically unless the user explicitly chose light mode, and the transition on :root creates a smooth color fade when the theme changes.
Adding transition to color changes and softening images in dark mode.
Smooth transition and image handling
❌ Wrong
/* No transition — jarring instant switch */
:root { --bg: white; --text: black; }
[data-theme='dark'] { --bg: #0f172a; --text: white; }
/* Images are blindingly bright in dark mode */
.hero-image { width: 100%; }✅ Fixed
:root {
--bg: white;
--text: black;
}
[data-theme='dark'] {
--bg: #0f172a;
--text: white;
}
body {
background: var(--bg);
color: var(--text);
transition: background-color 0.25s ease, color 0.25s ease;
}
/* Soften images and videos in dark mode */
@media (prefers-color-scheme: dark) {
img, video { filter: brightness(0.85) contrast(1.05); }
}
[data-theme='dark'] img,
[data-theme='dark'] video {
filter: brightness(0.85) contrast(1.05);
}Without a transition, switching themes causes an instant color snap that is visually jarring. Adding transition: background-color 0.25s ease, color 0.25s ease to the body (or :root) creates a 250ms fade between light and dark states. The timing is applied to background-color and color specifically rather than all properties to avoid transitioning layout-affecting properties like width or height, which would create unintended animation. The image filter reduces brightness in dark mode, which prevents photographs from appearing as bright light sources against a dark background. The brightness and contrast adjustments are tuned to reduce glare while preserving color accuracy.
Two approaches to CSS dark mode
Modern CSS dark mode is built on two browser features: the prefers-color-scheme media query and CSS custom properties. The media query reports the user's OS-level preference (dark or light), and custom properties serve as the color tokens that switch between the two themes. Understanding which layer controls what is essential for building a reliable implementation.
The prefers-color-scheme media query is purely declarative and requires no JavaScript. It fires whenever the OS color scheme changes, including dynamically when a user switches between light and dark in their system settings. This makes it the correct foundation for a base implementation — any user who has set their OS to dark mode will see dark colors without any JavaScript running.
The second layer is the in-page toggle. Most modern sites offer a button that lets users override the OS preference. This requires JavaScript because CSS cannot write to localStorage or toggle DOM attributes. The toggle needs to be coordinated with the media query: if the OS is dark and the user switches to light in the page, the page should stay light even if the OS later changes back to dark. This state is typically stored in localStorage.
The flash-of-incorrect-theme problem arises at the intersection of these two layers. If a React, Next.js, or Astro site reads the saved theme preference during JavaScript hydration — which happens after the first HTML paint — the page will flash the wrong theme for a fraction of a second before correcting itself. The only reliable solution is an inline script in the HTML head that reads localStorage and sets the data attribute synchronously before any paint occurs.
Diagnosing dark mode failures in DevTools
Chrome DevTools can emulate prefers-color-scheme without changing your OS settings. Open DevTools, go to the Rendering tab (More tools → Rendering), and set Emulate CSS media feature prefers-color-scheme to dark or light. This lets you test the media query path without toggling your OS theme, which is especially useful on Windows where the OS toggle requires navigating through several settings screens.
To check whether the flash-of-wrong-theme problem exists, open the Network tab and set the throttle to Slow 3G. Reload the page without cache. Watch the first few hundred milliseconds — if the page loads white before switching to dark, the theme is being applied after the first paint. The fix must be an inline script in the head, not a useEffect hook or a DOMContentLoaded listener.
For the color-scheme property check, inspect the html element in the Elements panel and look for color-scheme in the Computed styles. If it is not present or shows light, native browser elements like scrollbars and form inputs will not adopt dark mode appearance. Add color-scheme: light dark to :root and color-scheme: dark to the [data-theme='dark'] or the prefers-color-scheme dark block.
For the localStorage persistence issue, open the Application tab in DevTools and look at Local Storage for your domain. The theme key should be set to either 'dark' or 'light' after the user toggles. If it is missing or set to an unexpected value, the JavaScript event handler is not calling localStorage.setItem correctly, or the key name differs between the script that writes and the script that reads.
Building a complete dark mode implementation
Start by tokenizing all colors into CSS custom properties on :root. Every hardcoded hex value for background, text, border, surface, and shadow needs a corresponding token. The tokens form the theming API — instead of changing colors in dozens of rules, you change only the token declarations. This step cannot be skipped; without token-based colors, every dark mode selector must duplicate every color-using rule.
With tokens in place, add the media query block that overrides the token values for dark mode. Use :root inside the media query to match the specificity of the light-mode declarations. Override only the tokens that need to change — primary action colors like --accent or --brand often look fine without a dark-mode variant if they are chosen with sufficient contrast for both themes.
Add the [data-theme='dark'] selector with the same token overrides as the media query. This is what your JavaScript toggle will control. Guard the media query block with :root:not([data-theme='light']) so that a user who explicitly chose light mode stays light even if their OS switches to dark. This three-way state (OS dark, user override dark, user override light) is the complete model.
For the flash prevention script, place a small inline script immediately after the opening head tag. The script must be synchronous — no async or defer — so it runs before the browser paints. The script reads localStorage for a saved preference, reads window.matchMedia for the OS preference as a fallback, and calls document.documentElement.setAttribute('data-theme', 'dark') if either condition is true. This runs in under 1ms and makes the correct theme available before any CSS is applied.
Edge cases: SSR, images, and third-party UI
Server-side rendering frameworks like Next.js and Astro generate HTML before the browser has read localStorage, which means the server cannot know the user's saved preference. The standard solution is to render the page in a neutral state (or the light state as the default) and rely on the inline flash-prevention script to immediately set the correct data attribute before the CSS cascade runs. Some frameworks offer a cookies-based approach where the theme preference is sent with every request, allowing the server to render the correct theme without any flash.
Images and videos are a common oversight in dark mode implementations. Photographs rendered in full brightness against a very dark background create extreme contrast that feels jarring and causes eye strain. A subtle filter: brightness(0.85) contrast(1.05) on img and video elements reduces this glare while preserving color fidelity. SVG icons that are designed with dark strokes on transparent backgrounds may need an alternative version for dark mode, either via a media query inside the SVG or by switching the fill color through a custom property.
Third-party component libraries that use hardcoded colors are the most difficult dark mode challenge. Libraries like React Select, Date Pickers, or rich text editors often apply inline styles or component-scoped styles that cannot be overridden by custom properties on :root. The options are to override specific component styles with !important (fragile), to use a library version that supports custom property tokens (ideal), or to apply a filter: invert(1) hue-rotate(180deg) to the component as a last resort (approximate and not always accurate).
Mobile browsers on iOS and Android expose the OS color scheme through the same prefers-color-scheme API, but there are quirks. On iOS Safari, the browser chrome (status bar, tab bar) can be themed using the meta name='theme-color' tag with a media attribute: one for light and one for dark. Without this, the browser chrome stays white even when your page's background is dark, breaking the illusion of a seamless dark mode experience.
Dark mode mistakes that create subtle bugs
Not adding color-scheme: light dark to :root is the most common omission. Without it, the browser renders all native UI elements — scrollbars, text selection highlight, input backgrounds, checkbox ticks, select dropdown arrows — in light mode even when the page is visually dark. The fix is adding color-scheme: light dark to :root and color-scheme: dark to the dark theme selector. This one declaration makes native browser elements match the theme without any additional CSS.
Applying the theme class to body instead of html is a specificity trap. Custom properties cascade down the DOM tree from the element where they are declared. Declaring them on body instead of html (document.documentElement) means any content outside body — rare but possible in edge case layouts — will not receive the theme tokens. Using html (document.documentElement) as the theming root is the universal convention.
Adding transition: all to the root element creates performance problems. transition: all means every CSS property, including layout properties, will be animated when they change. This turns simple layout reflows into expensive animated transitions. Instead, explicitly list only the color-related properties: transition: background-color 0.25s, color 0.25s, border-color 0.25s, box-shadow 0.25s. Some developers also scope the transition to just body to avoid transitioning layout containers.
Not testing with real OS dark mode in addition to DevTools emulation. The DevTools emulation changes only the prefers-color-scheme media feature, but actual OS dark mode also changes system colors, native element appearances, and on macOS may affect certain WebKit-specific rendering behaviors. Test with the OS toggle at least once before shipping, particularly on iOS Safari where certain behaviors differ from desktop Chrome.
Best practices for maintainable dark mode
Design your token set with dark mode in mind from the start. Tokens named --bg-primary, --text-primary, --surface-elevated, --border-subtle, and --shadow-md are neutral names that can be redefined for any theme. Token names that embed their value — --white, --gray-100, --midnight-800 — break down when the theme changes because the name no longer accurately describes the token's role in the dark theme.
Limit the number of dark-mode-specific token overrides. A common mistake is to create entirely separate light and dark token sets with dozens of different values. Most interfaces only need four to eight token swaps to achieve a good dark mode: background, surface, text primary, text secondary, border, link color, and one or two semantic feedback colors. Minimize the override set and test each one for sufficient contrast against WCAG AA minimums (4.5:1 for normal text, 3:1 for large text).
Handle the no-JS case explicitly. If JavaScript is disabled or fails to load, the inline flash-prevention script will not run and the data attribute will never be set. The @media prefers-color-scheme path covers this case for users whose OS is set to dark mode, but a user who previously chose dark mode in your page toggle will see light mode with no JS. Either accept this tradeoff and document it, or use server-side preference storage via cookies to make the server-rendered HTML theme-aware.
Document the dark mode architecture in your CSS file with a short comment block. Explain the three-way state model (OS dark, user-override dark, user-override light), the data-theme attribute, the localStorage key, and which token set is the theming API. This reduces onboarding time for new developers and prevents well-intentioned changes from breaking the theme system. A clear comment on the :root rule pointing to the token override block is often enough.
Dark mode implementation checklist
- ✓Convert all hardcoded color values to CSS custom properties before adding dark mode
- ✓Add @media (prefers-color-scheme: dark) { :root { ... } } with overridden token values
- ✓Add color-scheme: light dark to :root and color-scheme: dark to the dark theme selector
- ✓Set data-theme on document.documentElement (html), not body
- ✓Place a synchronous inline script in head to prevent flash of wrong theme on load
- ✓Save user theme preference to localStorage and read it in the flash-prevention script
- ✓Add transition: background-color, color, border-color for a smooth theme switch animation
- ✓Test with OS dark mode and DevTools emulation, and on real iOS and Android devices
Related guides
Frequently asked questions
What is the simplest way to add dark mode to an existing site?
Convert all hardcoded color values to CSS custom properties on :root, then add @media (prefers-color-scheme: dark) { :root { ... } } with overridden token values. Also add color-scheme: light dark to :root. This provides automatic OS-based dark mode without any JavaScript. Add a data-theme toggle and localStorage persistence as a second phase if users need an in-page override.
How do I prevent the flash of white on page load in dark mode?
Place a synchronous inline script immediately after the opening head tag. The script reads localStorage for a saved theme preference and calls document.documentElement.setAttribute('data-theme', 'dark') before any CSS is applied. The script must not have async or defer — it must block parsing momentarily to run before the first paint. This is the only reliable solution for preventing the flash in SSR frameworks.
What is color-scheme: light dark and why is it important?
The color-scheme property tells the browser which color schemes the page supports. Setting color-scheme: light dark on :root makes native browser UI elements — scrollbars, form inputs, checkboxes, select dropdowns — automatically adopt dark styling when the OS is in dark mode. Without it, those elements stay light-colored even when the rest of the page is dark, creating an inconsistent appearance.
Should I put the data-theme attribute on html or body?
Put it on the html element (document.documentElement). Custom properties cascade down from where they are declared, and using html as the root ensures tokens are available to every element in the document including content outside body. It is also the universal convention used by Tailwind, Material Design, and most design systems, which means third-party components are more likely to respect it.
How do I handle images in dark mode?
Apply filter: brightness(0.85) contrast(1.05) to img and video elements inside the dark mode selector. This reduces the glare of bright photographs against dark backgrounds while preserving color fidelity. For SVG icons, switch their fill or stroke color through a custom property: fill: var(--icon-color). Avoid filter: invert(1) on photographs because it produces inaccurate colors and saturates skin tones badly.
How do I let users toggle dark mode independently of their OS setting?
Use a [data-theme='dark'] selector for the dark token overrides and a [data-theme='light'] guard in the prefers-color-scheme block. When the user toggles, call document.documentElement.setAttribute('data-theme', newTheme) and localStorage.setItem('theme', newTheme). The :root:not([data-theme='light']) guard ensures the media query only activates for OS dark mode when the user has not explicitly chosen light mode.
Can I use CSS system colors instead of custom properties for dark mode?
Yes. CSS system colors like Canvas (page background), CanvasText (default text), ButtonFace, and LinkText adapt to the OS color scheme automatically. They require no media query or JavaScript. However, they map to OS-level semantic colors rather than your brand palette, so most sites use them only as a fallback or for elements like form controls where the OS appearance is appropriate.
Does prefers-color-scheme work on mobile browsers?
Yes. iOS Safari (13+) and Chrome for Android (76+) support prefers-color-scheme and report the OS setting from the device's system preferences. iOS Safari also supports the meta name='theme-color' with a media attribute, which lets you set the browser chrome color separately for light and dark mode. Test on real iOS and Android devices because emulation in DevTools can miss device-specific rendering behaviors.
Why should I avoid transition: all for dark mode transitions?
transition: all applies transitions to every CSS property including layout properties like width, height, and margin. When any of those change for any reason, they will animate, creating unintended motion. Specify only the color-related properties: transition: background-color 0.25s, color 0.25s, border-color 0.25s, box-shadow 0.25s. This creates a smooth color fade when the theme changes without interfering with layout changes.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.