# Dark Mode with Web Standards

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`:

```js
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`:

```html
<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.

```css
.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:

```css
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:

```css
@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.
