CSS Dark Mode: prefers-color-scheme and light-dark()
How to build dark mode in CSS with the prefers-color-scheme media query and the light-dark() color function, without duplicating your palette.
Dark mode in CSS starts with the prefers-color-scheme media query, which reads the user’s OS-level light or dark preference, and is now cleanly paired with the light-dark() color function, which lets you define both variants of a color inline instead of duplicating whole rule blocks. Together they replace what used to be a sprawling set of overridden custom properties with a much smaller, more direct pattern.
The media query: detecting the preference
prefers-color-scheme matches light, dark, or is unset if the OS has no preference:
@media (prefers-color-scheme: dark) {
body {
background: #111;
color: #eee;
}
}
This is a read-only signal from the operating system — CSS can react to it, but can’t change it. If you want an in-page toggle that overrides the OS setting, you need a scripted approach: toggle a class or data-theme attribute on the root element and key your styles off that instead of (or in addition to) the media query.
The old pattern: duplicated custom properties
Before light-dark(), the common approach was to define a set of CSS custom properties at :root, then redefine every one of them inside the media query:
:root {
--bg: #ffffff;
--text: #111111;
--border: #dddddd;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111111;
--text: #eeeeee;
--border: #333333;
}
}
This works, but it means every color token has to be declared twice, in two different places, and it’s easy for the two lists to drift out of sync as a design system grows.
The light-dark() function
light-dark() takes two arguments — a light value and a dark value — and picks between them based on the current color scheme:
:root {
color-scheme: light dark;
--bg: light-dark(#ffffff, #111111);
--text: light-dark(#111111, #eeeeee);
--border: light-dark(#dddddd, #333333);
}
The color-scheme: light dark declaration is required — it tells the browser which schemes the page actually supports, and light-dark() resolves based on it. Both values for a token now live on the same line, so there’s one place to look and one place to edit. light-dark() isn’t limited to custom properties either; it works anywhere a color value is valid, including directly in background, border-color, or box-shadow declarations.
Scoping the scheme to part of a page
Because color-scheme is an inheritable property, you can scope a light or dark scheme to a subtree instead of the whole document — a code sample block that’s always dark, for instance, regardless of the page’s overall theme:
.always-dark {
color-scheme: dark;
background: light-dark(#ffffff, #1a1a1a);
}
Inside .always-dark, light-dark() resolves to its second argument even if the rest of the page is in light mode, because color-scheme: dark overrides the inherited scheme for that subtree.
Combining with a manual toggle
Most production sites still want a manual light/dark toggle in addition to respecting the OS preference — users expect to be able to override it. The common pattern layers a data-theme attribute on top of the media query:
:root {
color-scheme: light dark;
}
[data-theme="light"] {
color-scheme: light;
}
[data-theme="dark"] {
color-scheme: dark;
}
With no data-theme set, the page defers to prefers-color-scheme via color-scheme: light dark. Once JavaScript sets data-theme="dark" on <html> (typically persisted in localStorage), that explicit value takes precedence over the OS preference for every element under it. This is the same override pattern used by cascade layers more generally: a narrower, more specific scope wins without needing !important.
Where this replaces JavaScript
Before light-dark() and color-scheme, some of this — like styling native form controls, scrollbars, or the browser’s default focus ring for dark backgrounds — required either extra CSS overrides or JavaScript to swap classes. color-scheme alone now handles a lot of that: setting it tells the browser to render native UI elements (scrollbars, <select> dropdowns, form controls) in a matching light or dark style automatically, without any additional rules.
Browser and tooling notes
light-dark() and the color-scheme property are both part of the standard color and box-model specs, not experimental APIs — but as with any newer CSS feature, check current support before relying on it as your only theming mechanism for a site that needs to support older browsers. A @supports (color: light-dark(#fff, #000)) fallback block, styled with the older duplicated-custom-property pattern, is a reasonable safety net if you need one. Combined with CSS @property for more advanced typed custom properties, or container queries for layout that adapts independently of viewport size, this is part of a broader move toward doing more theming and responsiveness work in plain CSS instead of JavaScript.
The takeaway
prefers-color-scheme detects the user’s OS-level theme; light-dark() lets you express both color variants for a token in a single declaration instead of duplicating rule blocks inside a media query. Set color-scheme at the root so native form controls and scrollbars adapt automatically, and layer a data-theme attribute on top if you need a manual toggle that overrides the OS default. The result is a smaller, easier-to-maintain stylesheet than the old duplicated-custom-property approach, with less room for the light and dark palettes to drift apart.
Tagged
Keep reading
Takina · · 4 min read CSS object-fit and object-position, Explained
object-fit controls how an image or video is cropped inside its box, and object-position controls which part of it stays visible. How they work together.
Takina · · 5 min read CSS inherit, initial, unset & revert Explained
CSS's four global keywords control where a property's value comes from. How inherit, initial, unset, and revert differ, with a comparison table.
Takina · · 4 min read Dynamic Viewport Units: dvh, svh, and lvh Explained
dvh, svh, and lvh fix the classic mobile vh bug where browser toolbars cut off full-height layouts. Here's what each unit measures and when to use it.