Implementing dark mode correctly, without a flash of wrong theme
prefers-color-scheme is more than a media query for light and dark. The color-scheme property, a clean user override and the right order at first render decide whether dark mode feels seamless or starts with a visible flash of the wrong theme.
Table of Contents
- 1. What prefers-color-scheme is and where the value comes from
- 2. Media query syntax: light, dark and the deprecated no-preference
- 3. The color-scheme property and native form elements
- 4. Switching custom properties automatically per color scheme
- 5. User override despite an operating system setting
- 6. Testing in DevTools and catching regressions
- 7. Avoiding a flash of wrong theme with SSR
- 8. Combining with prefers-contrast and forced-colors
- 9. Implementation approaches in direct comparison
- 10. Summary
- 11. FAQ
1. What prefers-color-scheme is and where the value comes from
prefers-color-scheme is a media query that answers a single question: does the user's operating system prefer a light or a dark color scheme. The value does not come from the website itself, it is set globally at the operating system level, for example in the system settings of macOS, Windows, Android or iOS, and applies there to every application at once that respects this setting.
For websites, this means: prefers-color-scheme allows implementing a dark mode that automatically matches the user's wider operating system environment, without building a dedicated toggle. This is the key difference to a manually implemented theme switch: the setting comes from outside, from a decision the user has already made, instead of a new decision that first has to be made on the given website.
2. Media query syntax: light, dark and the deprecated no-preference
The syntax of prefers-color-scheme is straightforward: @media (prefers-color-scheme: dark) { ... } applies when the operating system prefers a dark scheme, @media (prefers-color-scheme: light) { ... } applies for the light scheme. A third possible value, no-preference, was removed from the specification because in practice operating systems almost always deliver an explicit preference and the value was barely used.
A common beginner mistake: developers first build a complete light theme and then add a prefers-color-scheme: dark media query with overrides for every single color. This creates duplicated maintenance work and color values that easily drift apart. The more robust approach defines custom properties as an intermediate layer and only overrides those inside the media query, instead of duplicating every single CSS rule.
/* Naive approach: duplicating rules per color scheme (avoid this) */
.card { background: white; color: #111827; }
@media (prefers-color-scheme: dark) {
.card { background: #1e1b2e; color: #f8fafc; } /* duplicated selector */
}
/* Better: override custom properties, keep the rule itself unchanged */
:root {
--surface: white;
--on-surface: #111827;
}
@media (prefers-color-scheme: dark) {
:root {
--surface: #1e1b2e;
--on-surface: #f8fafc;
}
}
.card { background: var(--surface); color: var(--on-surface); }
3. The color-scheme property and native form elements
Besides the media query, there is the CSS property color-scheme, which tells the browser which color schemes a page supports. With :root { color-scheme: light dark; }, a page signals that it handles both light and dark rendering. The browser then automatically adjusts native UI elements that are not styled directly through CSS, such as scrollbars, form fields, checkboxes and date picker widgets.
Without this property, an inconsistent picture frequently emerges: the custom styled page content correctly follows prefers-color-scheme and looks dark, while native scrollbars and form elements still appear light and jarring in between. The color-scheme property fixes exactly this problem by including native elements in the theme decision, without every single element needing to be manually restyled.
/* Tell the browser which color schemes the page supports */
:root {
color-scheme: light dark;
}
/* Native form elements, scrollbars and date pickers now
automatically follow prefers-color-scheme without extra CSS */
input, textarea, select {
background: var(--surface);
color: var(--on-surface);
}
4. Switching custom properties automatically per color scheme
The combination of prefers-color-scheme and custom properties is the standard approach for maintainable themes. Instead of maintaining two completely separate stylesheets, a single set of semantic variables gets defined, for example --surface, --on-surface and --border, and only their values get overridden inside the prefers-color-scheme media query. Every component references only these semantic variables, never direct color values.
This approach scales well, because new components become themeable automatically as soon as they use the existing semantic variables, with no additional theme code needed. Since the newer light-dark() function arrived, the same logic can even be expressed more compactly directly inside the variable definition, without writing the media query explicitly at all, though prefers-color-scheme and the color-scheme property still form the foundation for it.
5. User override despite an operating system setting
Many products want to offer a manual theme toggle despite prefers-color-scheme, so users can choose independently of the system setting. The common pattern: a data-theme attribute on the html element, set through JavaScript and stored in localStorage, overrides the automatic prefers-color-scheme detection when the user explicitly makes a choice. If the attribute is absent, the system setting continues to act as the default.
What matters for a clean interaction: the CSS selectors for the manual override must be more specific than the plain media query, for example [data-theme="dark"] as an attribute selector that specifically overrides the media query rules. This way, prefers-color-scheme remains the sensible default, while an explicit user choice always takes priority, no matter what the operating system reports.
/* System preference as the default */
:root {
--surface: white;
--on-surface: #111827;
}
@media (prefers-color-scheme: dark) {
:root { --surface: #1e1b2e; --on-surface: #f8fafc; }
}
/* Explicit user override always wins, regardless of the OS setting */
:root[data-theme="light"] { --surface: white; --on-surface: #111827; }
:root[data-theme="dark"] { --surface: #1e1b2e; --on-surface: #f8fafc; }
// Persist and apply an explicit user override
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}
const saved = localStorage.getItem('theme');
if (saved) {
document.documentElement.setAttribute('data-theme', saved);
}
// Without a saved value, prefers-color-scheme remains the source of truth
6. Testing in DevTools and catching regressions
Chrome, Firefox and Safari allow emulating prefers-color-scheme independently of the actual operating system setting in the DevTools rendering tab. This is essential, because switching the real system setting during development is cumbersome, and both states need to be tested regularly. A frequently overlooked test case: elements that are only visible in one of the two schemes, for example icons with a hard coded color instead of a custom property, immediately stand out during this test.
prefers-color-scheme can also be emulated through a browser context option for automated visual regression testing with Playwright or Puppeteer, so screenshot comparisons for both themes run independently of each other in the CI pipeline. This reliably reveals when a new component accidentally uses a hard coded color instead of a themeable variable.
7. Avoiding a flash of wrong theme with SSR
On server rendered pages, a brief flash of the wrong theme can occur if the server does not know the theme and the browser first renders the default theme, before JavaScript applies the saved user override. This problem is independent of prefers-color-scheme itself, but frequently shows up in combination with a user override, because the server does not know the browser's localStorage value.
The common fix: a tiny inline script in the <head>, executed before the first paint of the page, which sets the data-theme attribute synchronously, before the browser even begins rendering. Since prefers-color-scheme itself works purely declaratively without any JavaScript, this problem exclusively affects pages with an additional manual override, not the pure automatic system mode.
<!-- Inline script in <head>, runs before first paint -->
<script>
(function () {
var saved = localStorage.getItem('theme');
if (saved) {
document.documentElement.setAttribute('data-theme', saved);
}
})();
</script>
8. Combining with prefers-contrast and forced-colors
prefers-color-scheme only covers the light versus dark axis, not contrast requirements. The related media query prefers-contrast detects whether the user prefers increased or reduced contrast, and forced-colors detects the Windows High Contrast Mode, in which the browser enforces system provided colors of its own. All three media queries can be combined independently, because they represent different user preferences.
A fully accessible color system respects prefers-color-scheme for the basic light versus dark choice, prefers-contrast for additional contrast levels within each scheme, and honors forced-colors by not enforcing its own background colors there, leaving control to the operating system instead. Projects that only implement prefers-color-scheme and ignore the other two only cover part of the actual user preferences.
9. Implementation approaches in direct comparison
The following table compares three common implementation approaches for dark mode.
| Criterion | Media query only | Media query + override | JS toggle only |
|---|---|---|---|
| Follows system setting | Yes, automatically | Yes, as default | No |
| Manual user choice | Not possible | Yes, takes priority | Yes |
| JavaScript required | No | Only for persistence | Yes, mandatory |
| Flash risk with SSR | None | Avoidable with inline script | High without inline script |
| Maintenance effort | Low | Medium | High |
For most projects, the combination of prefers-color-scheme as the default and an optional, attribute based overriding user toggle is the best trade off. Pure JS toggles that ignore the system setting disregard a decision the user has already made, and should only be the sole solution in exceptional cases.
Mironsoft
Dark mode implementation, theme architecture and accessibility
Dark mode with no flash of wrong theme?
We implement prefers-color-scheme with a clean user override, no visible flash on first load, and make sure native form elements, contrast preferences and Windows High Contrast Mode work together correctly.
Theme architecture
Semantic custom properties instead of duplicated color rules per scheme
SSR fix
Inline script against flash of wrong theme on server side rendering
Accessibility
Correct combination with prefers-contrast and forced-colors
10. Summary
prefers-color-scheme is the starting point for every modern dark mode, but a truly clean result only emerges in combination with further techniques. The color-scheme property brings native UI elements into the theme decision, semantic custom properties reduce maintenance effort, and an attribute based user override allows a deliberate deviation from the system setting, without losing the media query as a sensible default.
On server rendered applications, a tiny inline script in the head prevents the flash of the wrong theme, while combining with prefers-contrast and forced-colors ensures that users with special contrast requirements are fully accounted for too. Bringing all these building blocks together results in a dark mode system that feels like a native, seamless operating system feature to users.
prefers-color-scheme in Detail — The Essentials at a Glance
Basic pattern
@media (prefers-color-scheme: dark) overrides custom properties, never duplicate individual CSS rules directly.
color-scheme property
color-scheme: light dark; automatically adjusts native scrollbars and form elements.
Flash of wrong theme
An inline script in the head sets the attribute synchronously, before the page's first paint.
Full accessibility
Consider prefers-contrast and forced-colors in addition to prefers-color-scheme.