prefers-color-scheme in Detail: Implementing Dark Mode Correctly
AI generated
{ }
@
CSS · Dark Mode · Accessibility · Media Queries
prefers-color-scheme in Detail
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.

19 min read prefers-color-scheme · color-scheme · localStorage · forced-colors All modern browsers

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.

11. FAQ: prefers-color-scheme in Detail

1What is prefers-color-scheme?
A media query checking the OS light/dark preference, set in the system settings.
2Why the color-scheme property?
Automatically adapts native elements like scrollbars and form fields to prefers-color-scheme.
3How do I build a toggle?
Set a data-theme attribute via JavaScript, store in localStorage, keep the selector more specific than the media query.
4What is flash of wrong theme?
A brief flash of the wrong theme on SSR pages, avoidable with an inline script in the head before rendering.
5Difference to prefers-contrast?
color-scheme concerns light/dark, prefers-contrast concerns contrast levels. Both should be combined.
6How to test without changing settings?
In the DevTools rendering tab the media query can be emulated independently of the real setting.
7Duplicate rules per scheme?
No, use custom properties as an intermediate layer and only override those inside the media query.
8What is forced-colors?
Detects Windows High Contrast Mode, withdraw custom colors there and leave control to the system.
9Does no-preference still exist?
No, removed from the spec since systems almost always deliver an explicit preference.
10Is a pure JS toggle enough?
Not recommended, ignores the system setting. Better to use prefers-color-scheme as default with an override.