Articles

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.

Takina Takina · · 4 min read
Close-up of CSS code on a monitor

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.

Takina 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.

#CSS #Web Development #Frontend
Takina 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.

#CSS #Web Development #Frontend
Takina 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.

#CSS #Web Development #Frontend