Dark Mode with Web Standards

title
Dark Mode with Web Standards
type
summary
summary
Why an in-page dark mode toggle can't use prefers-color-scheme, and what to use instead
tags
web-standards, css, frontend
created
2026-07-23
updated
2026-07-29

Ollie Williams walks through what it takes to ship dark mode with no framework and no class="dark" on the root element, and where the platform still doesn't cooperate. Two requirements drive it: honour the OS setting for a first-time visitor, and let that visitor override it per site. Someone may well want a dark application UI and light long-form reading.

The meta tag, not the media query

A document's color scheme can be declared two ways โ€” the CSS color-scheme property on html, or a <meta name="color-scheme"> tag in the head. Williams recommends the meta tag, because CSS can be slow to arrive on a bad connection and the scheme should be settled before first paint. Ship content="light dark" to follow the OS, then have the toggle rewrite the attribute to light, dark, or back to light dark, persisting the choice in localStorage:

const metaTag = document.querySelector('[name="color-scheme"]');

const savedScheme = localStorage.getItem("colorScheme");
if (savedScheme) { metaTag.setAttribute('content', savedScheme); }

btndark.addEventListener('click', function() {
    metaTag.setAttribute('content', 'dark');
    localStorage.setItem("colorScheme", "dark");
});
btnsystem.addEventListener('click', function() {
    metaTag.setAttribute('content', 'light dark');
    localStorage.removeItem("colorScheme");
});

Setting the scheme drives colors, gradients and images written with light-dark(), system colors like Canvas and CanvasText, scrollbars, the default rendering of form controls and buttons, iframes whose documents opted in via their own meta tag, and SVGs that use light-dark() or prefers-color-scheme.

The gap between color-scheme and prefers-color-scheme

prefers-color-scheme reports the OS setting and ignores color-scheme entirely. That is the whole problem with an in-page toggle: any styling written behind the media query keeps following the operating system while everything else follows the button the user just pressed.

The clearest casualty is art direction. This does not respond to the toggle, and there is no color-scheme equivalent short of moving the image into a background-image:

<picture>
  <source srcset="logo-dark.png" media="(prefers-color-scheme: dark)" />
  <img src="logo-light.png" alt="Product logo" />
</picture>

Two contexts are exceptions, where a parent document's color-scheme does reach the child's media query: iframes and SVG. An <iframe style="color-scheme: dark;"> renders its document's @media (prefers-color-scheme: dark) rules, and the same holds for an <img> pointing at an SVG that carries its own <style> block.

The CSS Working Group has resolved to make prefers-color-scheme reflect the document's declared scheme in all contexts (csswg-drafts PR 13857, from the resolution in issue 13377). No browser has implemented it; the post links the Chromium issue for anyone who wants to vote it up.

Safari's state as of Safari 27: prefers-color-scheme inside SVG now works, but color-scheme does not override it (WebKit bug 316640). Inside iframes it works and the parent's color-scheme does override it correctly, with other bugs still open (WebKit bug 316680).

light-dark() beyond colors

light-dark() started out restricted to colors and now accepts gradients and images too, shipping in Chrome and Edge 150, Firefox 150, and Safari Technology Preview.

.bg-gradient {
  background-image: light-dark(linear-gradient(15deg, #b9b6ff, #308dc6), linear-gradient(15deg, #6b7495, #001339));
}
.bg {
  background-image: light-dark(url(/lightmode.avif), url(/darkmode.avif));
}

A gradient can even be swapped for a flat color by wrapping the color in image().

Changing things that aren't colors

Sometimes the difference between modes isn't a color value. A box-shadow that reads well on white disappears on a dark background, and a border is the better answer there. There is no way to ask "which scheme am I in" from CSS today. The CSSWG is discussing an if() statement or a style query for it; nothing is implemented.

Two hacks work now. The first sets a custom property from the meta tag's value and queries it:

html { --dark: false; }
html:has([content="light dark"]) {
  @media (prefers-color-scheme: dark) { --dark: true; }
}
html:has([content="dark"]) { --dark: true; }

@container style(--dark: true) {
  .card { border: solid 1px rgb(94, 94, 94); }
}

Williams prefers the second, which registers a property typed as <color>, assigns it through light-dark(), and style-queries the used value โ€” so the browser resolves the scheme instead of the stylesheet re-deriving it:

@property --usedScheme {
  syntax: "<color>";
  inherits: true;
  initial-value: transparent;
}
body { --usedScheme: light-dark(white, black); }

@container style(--usedScheme: black) {
  .card { border: solid 1px rgb(94, 94, 94); }
}

Style queries are supported in all browsers now, which is what makes either version viable.

What might land later

There is a draft way to override prefers-color-scheme from JavaScript โ€” the script-control-user-prefs section of Media Queries 5, an MDN entry for a User Preferences API, and a prototype in Chrome Canary. The Safari team is on record opposing it, so the meta-tag approach is the one to build on for now.

The color-scheme declaration also appears in the Foundations tier of specification-website-checklist, where the reason given is narrower and more practical: it prevents the white flash before dark-mode CSS loads.

misago-react-to-htmx applies the same instinct at application scale โ€” server-rendered htmx islands instead of a client framework, with the bundle numbers attached to show what it recovered.