Responsive design introduced prefers-color-scheme as one of several media features that report the reader's preferences, and page 05, Choosing and pairing, built role tokens with a value for each scheme. This page puts the two together. It starts with what the browser does for you once you declare your schemes, adds the one function that lets every token carry both colors, builds a switch that respects the system setting, and then turns to the part that needs a designer's eye: what a good dark palette looks like, why inverting a light one fails, and what to do with images, icons and the browser's own chrome. Two demos let you try a working switch and compare an inverted page with a designed one.
1. Two palettes, one page
Every major operating system has offered a dark appearance since 2019 or earlier, and browsers report the reader's choice to pages. Readers choose dark for many reasons: a dim room, a bright screen at night, a migraine, a habit, or eyes that find a dark page easier. Others keep light mode for the same variety of reasons. A page that ignores the setting forces a bright white rectangle onto a dark desktop, or the reverse, and a page that guesses wrong in either direction makes some reader's day worse. The goal is not "support dark mode" as a feature. It is that the page look intentional in whichever scheme the reader is using.
Four decisions cover it, and the rest of this page takes them in order:
- Declare the schemes you support, with
color-scheme, so the browser draws its own parts (the page background, form controls, scrollbars) to match. - Default to the reader's system setting. The setting is already a choice the reader made; the page should honor it before offering anything else.
- Let the reader override it, with a switch that has three states (System, Light and Dark), remembered across visits and applied before the page is first drawn.
- Design both palettes. A dark palette is chosen, not computed: surfaces, text, brand, status colors and every contrast pair are decided again for a dark ground.
The first three are platform features and a few lines of code. The fourth is where most dark modes go wrong, and where the developer, as the bridge between design and code, often has to ask for a second set of swatches instead of improvising one.
prefers-color-scheme; the reader's choice reaches it through color-scheme on the root, which everything on the right follows.2. Declaring schemes: color-scheme
Before any style sheet runs, the browser draws parts of the page itself: the background of the canvas, the default text color, the system colors such as Canvas and CanvasText, form controls, scrollbars, and the spelling underline. By default it draws all of them light, whatever the reader's setting, because a page that never asked for dark might have hard-coded black text somewhere, and dark controls under black text would be unreadable. The color-scheme property is how a page says it is ready.
The values, in short:
normal, the initial value: the page has not said, so the browser uses its default scheme, which in practice is light.lightordark: the page supports only that scheme, and the browser draws its parts in it whatever the reader prefers.light dark: the page supports both. The browser uses the one the reader prefers, so with a dark system setting the canvas, controls and system colors turn dark. The order matters only when the reader's scheme is not in the list: then the browser uses the first one it supports.only, added to either:only lightasks the browser not to darken the page on its own. Some browsers, notably Chrome on Android with its automatic dark theme for web content, can force a dark rendering onto pages that declare nothing;onlyopts out.
It is an inherited property, so setting it on the root covers the page, and setting it on one element (a code sample that must stay dark, an embedded widget that only has a light design) covers that subtree. In Chrome 156 with a dark system setting, a page with no color-scheme computes Canvas as white and its body text as black; add color-scheme: light dark and Canvas becomes rgb(18, 18, 18) with white text. Note that the browser's own dark background is not pure black either, for the reasons section 7 gives.
color-scheme is the quiet half of every theme. It does not change any color you set; it changes the colors you did not set, which is why a page that switches its own tokens to dark but forgets color-scheme ends up with white date pickers and light scrollbars on a dark page. It has been Baseline since February 2022 and widely available since August 2024.
The meta tag, before any CSS
A style sheet arrives after the HTML has started to arrive, and the browser may paint before it loads. For a reader with a dark setting, that first paint is a white page, and when the style sheet arrives the page snaps to dark: the "white flash". The color-scheme meta tag declares the same thing in the HTML itself, so the browser knows before it paints anything:
Use both: the meta tag for the first paint, and the property in the style sheet, where it can be changed per element and by the switch in section 5. The meta tag is supported in Chrome and Edge 81, Firefox 96 and Safari 12.1.
3. Asking the system: prefers-color-scheme
The prefers-color-scheme media feature reports the reader's setting as light or dark. It usually follows the operating system, though some browsers, Firefox among them, let the reader set a website appearance of their own. It has been widely available since July 2022. Its classic use is a media query that swaps a set of tokens:
That still works, and every browser that supports custom properties supports it. Its weakness shows up when the reader overrides the system with a switch: the media query answers only what the system says, so every token block has to be written twice more, once for [data-theme="dark"] and once for the system case, and the two copies drift. Section 4 removes the duplication.
The same media feature works in more places than CSS, and all of them follow the system, never the page:
- In
<picture>:<source media="(prefers-color-scheme: dark)" srcset="chart-dark.png">picks an image per scheme. Images in HTML shows it; section 8 deals with its limit. - In
<meta name="theme-color">, through itsmediaattribute, to tint the browser's own interface per scheme (section 9). - Inside an SVG, including an SVG favicon, where a
<style>element can hold the query. - In a request header,
Sec-CH-Prefers-Color-Scheme, for a server that asks for it (section 11).
To test any of this without changing your operating system, Chrome's DevTools can emulate the feature: open the Command Menu, choose "Show Rendering", and set "Emulate CSS media feature prefers-color-scheme". Firefox's inspector has buttons for light and dark simulation.
In a script: matchMedia()
Scripts ask the same question with matchMedia(), which returns a MediaQueryList. Its matches property is the answer now, and its change event fires when the answer changes, which it does while the page is open: many readers set their system to switch to dark at sunset, and the page should follow without a reload.
Older examples use dark.addListener(fn), which is deprecated in favor of addEventListener('change', fn). Prefer CSS where CSS can do the job: a media query or light-dark() follows a live change with no code at all. Reach for matchMedia() when a script draws color itself, on a canvas or in a chart library, or when a switch needs to say what "System" currently means, as the demo in section 5 does.
4. One token, two colors: light-dark()
light-dark(a, b) is a color that is a when the element's used color scheme is light and b when it is dark. The used scheme comes from color-scheme: with light dark it is the reader's preference, and with light or dark it is that scheme. So a token set needs no media query at all:
Each role states both of its values in one place, side by side, where a reviewer can compare them and a test can check both. And the switch costs two lines: changing color-scheme on the root changes the used scheme of every element below it, and every light-dark() on the page flips at once, along with the controls, scrollbars and system colors from section 2. One mechanism serves both the system setting and the reader's override, so the two cannot drift apart.
Two details matter in practice. First, light-dark() is resolved on the element that uses the color, by that element's scheme, so a custom property defined on the root but used inside a color-scheme: dark panel takes the dark value there. That is usually what you want, and it lets a single component be forced into one scheme. Second, it chooses between two values; it does not compute anything. The dark value is a separate decision, made by a designer, which is the subject of section 7.
Support, and a fallback
light-dark() reached the last major engine, Safari 17.5, on 13 May 2024, after Firefox 120 and Chrome 123, which makes it Baseline newly available. Widely available is 30 months later, so it crosses that line in mid-November 2026, a few weeks from this writing. For most audiences it is safe as the main mechanism; for readers on browsers from before 2024 it is worth a fallback.
The fallback has a trap. A token is a custom property, and the browser stores --surface: light-dark(…) without checking it, so writing a plain value first and the light-dark() token second does not fall back: an old browser gets an invalid value at computed-value time and the property becomes unset. Page 04, Deriving color, explains why. Ask instead:
That gives an old browser a working light page; if it must also get dark, repeat the dark values inside @media (prefers-color-scheme: dark) within the same @supports block. The theme-toggle demo below does the first, and says in its readout which path your browser took.
Since September 2026, light-dark() also accepts two images, which page 04 lists with its support. Images are the subject of section 8.
This site's theme
This site is built on exactly this pattern. Its theme, src/assets/css/powell-theme.css, defines primitives (navy, gold and gray scales, each an oklch() value with the designer's hex in a comment, as page 03 told) and role tokens on top of them, and every role that differs between schemes is a light-dark() pair of primitives. Page 05 showed the tiers; here is what the pairs decide:
The dark palette is not the light one turned over. The page is navy, not black, and the surfaces step lighter as they rise, --navy-950 for the page, --navy-900 for surfaces, --navy-800 for raised ones. Text is a pale blue-gray, not white. The brand changes role: navy is the structure in light mode and cannot be an action color on a navy page, so the primary becomes gold. And the accent text is a different gold in each scheme, because the bright gold that reads at 11:1 on navy measures 1.5:1 on white. test/theme-contrast.test.js computes those ratios from the oklch() values on every test run, in both schemes.
The theme's header comment says how the switching works: "Colors resolve through light-dark(), so VB's data-mode and the OS preference still switch light and dark." Vanilla Breeze, the component library the site is built on, declares color-scheme: light dark on the root and maps data-mode="light" and data-mode="dark" on the root to color-scheme: light and dark, the same two lines as above. Its theme manager, which starts when the library's module script runs, reads a saved mode from localStorage (the key vb:theme:current), defaulting to "auto", sets data-mode to the saved mode or, for "auto", to what matchMedia() reports, and listens for the system setting to change. Because the manager runs in a module script, a stored mode other than auto would arrive after the first paint, the late-script case of section 5. The site does not currently put a theme switch on its pages, so no reader stores one: in practice it follows your system setting, from the first frame, through color-scheme and the tokens.
5. A switch with three states
Many sites offer a two-state toggle: a sun and a moon. The trouble, as Kilian Valkhof argued in "Your dark mode toggle is broken" (April 2020), is what happens after the first click. Once the reader has chosen Light or Dark, the stored choice overrides the system for good, and there is no way back to following the system short of clearing site data. A reader whose system switches to dark at sunset now has a site that stays light all evening. "System" is a choice too, and the default one, so the switch needs three states.
A radio group is the honest control for one choice among three: it has a label, a keyboard pattern every screen reader knows, and a visible state. The group starts hidden because without the script it would do nothing, and without the script the page still follows the system through color-scheme, which is the right default. The script that drives it is short:
The script never sets colors. It sets one attribute, and the two CSS rules from section 4 turn the attribute into color-scheme. Choosing System removes both the attribute and the stored value, so the page goes back to light dark and follows the system again, live. Storage is wrapped in try because localStorage can throw: in some private windows, with site data blocked, or in a sandboxed frame. A switch that breaks the page when storage is unavailable is worse than no switch.
Before the first paint
The stored choice has to be applied before the browser draws anything, or a reader who chose Dark on a light system sees a light page for a moment on every visit, then a jump to dark. Scripts loaded at the end of the body, deferred scripts and module scripts all run after the document is parsed, and the browser is free to paint before that. The fix is a tiny classic script in the <head>, which runs while the head is parsed, before the body exists:
It is a few hundred bytes and blocks parsing for well under a millisecond, which is the price of a correct first paint. It accepts only the two values it knows, so a corrupted or tampered value means System. Inline scripts need a hash or nonce under a strict Content Security Policy; keep that in mind before copying the pattern into a site that has one.
The store's switch
The fixed Trail report on Terra Outfitters does all of this and marks it COLOR FIX #1. Its tokens are light-dark() pairs of oklch() values under color-scheme: light dark, with the two [data-theme] rules after them. A classic script in the head reads terra-theme from localStorage inside try and sets data-theme only for light or dark. The switch is a fieldset of three radio buttons, System, Light and Dark, shipped hidden and revealed by the page's one script, which applies a change and stores it, again inside try. Its source comment puts the design in one line: "three states, because 'System' is a choice too. Without the script there is no switch, and the system decides." The same script also redirects the page's one <picture>, which section 8 explains.
6. The shortcut that fails: inverting
There is a one-line dark mode, and it is tempting because it seems to work on a page of black text on white:
This is a bad sample, and it is SIN #1 of the discolored Trail report. invert(1) subtracts every channel from its maximum: in Chrome 156 it works on the 8-bit sRGB values, so the store's brand orange, #b54f17, becomes #4ab0e8, a sky blue. White and black swap, but so does everything else. The store's own comment lists the damage: "the orange brand turns blue, the green header turns pink, and the lake photo becomes a negative." It also notes what the filter does not do. The page declares no color-scheme, so the browser draws light form controls and scrollbars, and then the filter inverts those too. A second comment, on the chart image, adds that inverting "happens to look passable" for a simple drawing; the photo above it "is not so lucky".
Inversion fails because hue carries meaning and photographs carry information. A brand color is a brand only in its hue, and inverting it produces the brand of a competitor. Green for "in stock" turns magenta, and red for an error turns cyan. Skin tones turn blue-gray. Shadows, which were darker than the surface, become pale glows, and a white card that floated above a warm page becomes pure black, darker than the page, so it seems to sink. Some write filter: invert(1) hue-rotate(180deg) to put the hues roughly back, then invert the images a second time to restore them; the result still has pure black surfaces, glowing shadows, and colors that were never chosen. It is a hack with a list of exceptions that only grows.
The fixed Trail report replaces the filter with a second palette. Its --surface is light-dark(oklch(96.7% 0.0082 91.48), oklch(21.2% 0.0098 145.22)), a warm paper in light and a very dark green-gray in dark, and its brand is light-dark(oklch(55.14% 0.1471 44.6), oklch(74% 0.14 50)): the same orange family in both, lighter in dark, with a dark label on it. Its source comment above the photo says it plainly: "the photo is the same photo in dark mode."
7. Designing dark on purpose
A dark palette is a design of its own. The guidance below is a set of tendencies with reasons, gathered from platform design systems and from what fails in practice; each one is a default to depart from on purpose, not a rule.
- Avoid pure black surfaces. White text on pure black has the highest possible contrast, 21:1, and that is often more than a reader wants for long reading: thin strokes of bright text on black can seem to bleed or shimmer, especially for readers with astigmatism. A very dark gray or a dark tint of the brand hue reads more calmly and leaves room below it for shadows and for darker elements. The browsers agree: Chrome's own dark
Canvasisrgb(18, 18, 18), aboutoklch(18% 0 0). This site's darkest surface is navy at 20% lightness; the store's is a green-gray at 21%. On OLED screens pure black does turn pixels off and saves some power, so an app built for battery life may choose it deliberately, but it should be a decision rather than a default. - Show elevation with lighter surfaces, not shadows. A shadow is darker than what it falls on, and on a dark page there is little darker left to show it. Material Components for Android's dark-theme guide makes the point directly: "Shadows are less effective in an app using a dark theme," and "Material surfaces become lighter and more colorful at higher elevations." In
oklch()this is a lightness step per level: a page at 19%, cards at 24%, menus and dialogs at 29%. - Lower the chroma of large areas. A saturated color that sits comfortably on white looks louder on black, because the eye adapts to the dark surroundings and the colored area dominates the field. Keep vivid color for small things, such as buttons, icons and focus rings, and give banners, panels and selected rows a darker, less colorful version of the hue.
- Give the brand a dark-mode variant. A brand color tuned to read on white is usually too dark to read on a dark surface, and too dark to carry a light label. The dark variant keeps the hue and raises the lightness, often lowering the chroma a little, and usually takes a dark label. This site goes further and swaps roles (navy to gold); the store keeps its orange and lightens it from 55% to 74%.
- Do not make text pure white. A slightly dimmed, slightly tinted off-white (lightness around 90 to 94%) keeps contrast high and glare low, and sets up a clear step to muted text.
- Recheck every pair. Every text and background pair changes when the scheme changes, so each needs its ratio checked in both. Status colors need the most care: the red that reads on white at 4.5:1 is usually too dark on a dark surface, and each status needs a lighter text version for dark. Page 08, Color for people, covers the measuring.
Lightness in oklch() makes this work faster. Because L tracks perceived lightness, a dark palette can be sketched by reflecting the light palette's lightness values and then adjusting: text near 93% where light mode had 25%, surfaces near 20% where it had 98%, a brand around 75% where it had 52%. Hue stays where it was. Chroma comes down for large areas. Then every pair gets measured, because a sketch is not a check.
8. Images in both schemes
Photographs are the same photographs in the dark. A photo of a lake at noon is meant to look bright; inverting it destroys it, and swapping it for a different photo changes the content. Leave photos alone. Some designers dim photos slightly in dark mode, with a mild filter: brightness(), so a bright image does not glare out of a dark page; that is a judgment call to try on the actual images, since it also changes what the photographer made.
Drawings are different. A chart, a diagram or a screenshot of an interface was drawn for one background, and a white rectangle with dark lines is jarring on a dark page. There are three ways to make a drawing follow the scheme, in order of preference:
- Draw it in SVG with
currentColorand custom properties. An inline SVG whose strokes usecurrentColorand whose fills use your tokens follows the scheme like any text, including the reader's override and forced colors. The lines and labels of this page's diagrams work this way. SVG styling shows how; Color in CSS makescurrentColora habit. - Provide a dark version and choose it in CSS, where the image is decorative:
background-imagewith two images inlight-dark(), which followscolor-schemeand therefore the switch. - Provide a dark version and choose it in HTML, where the image is content:
<picture>with a<source media="(prefers-color-scheme: dark)">. This follows the system setting only.
The second option is new. light-dark() with images shipped in Firefox 150 in April 2026, Chrome 150 on 30 June, Edge 150 in early July, and Safari 27 on 14 September 2026, which made it Baseline newly available. Page 04's support table lists it. So it goes behind @supports, with a media-query version as the fallback:
The third option has the limit the outline of this section calls worth showing. A <source>'s media attribute is a media query, and media queries ask about the system, not about the page. In Chrome 156, with the system set to dark and the page's root set to color-scheme: light, light-dark() resolves to its light value while the <picture> still loads its dark source. A reader who chose Light on a dark system gets a light page with a dark chart in it. The fix is for the switch to rewrite the attribute, as the store's script does:
all always matches and not all never does, so Dark forces the dark source, Light forces the light one, and System restores the original query. The store uses this for its "Trail visitors" bar chart, which has a separate dark version, and leaves the lake photo alone. On the discolored copy, the same chart has only one version, and the filter inverts it along with everything else.
9. Browser chrome, icons and controls
A page's colors do not stop at its edges. Three pieces of the browser's own interface can follow the scheme too, with very different support.
theme-color
The theme-color meta tag suggests a color for the browser's interface around the page, such as the toolbar or the status bar, and its media attribute lets a page give one color per scheme:
Where it is used has narrowed. MDN's compatibility data, updated October 2026, says Chrome for Android uses it in the toolbar; Chrome and Edge on the desktop use it only for installed web apps; Safari used it from version 15 to tint its tab bar, but from Safari 26 only for installed web apps; and Firefox does not support it. webstatus.dev lists it as limited availability. The media attribute asks the system, like <picture>, so a switch that should also change the toolbar has to update the tag's content. The values are hex here, a deliberate exception to this section's rule: the color is drawn by the browser outside the page, and hex is the notation every implementation is documented to accept. Use it as polish, and pick colors that match the top of the page so the edge disappears.
Favicons
An SVG favicon is an SVG document, so it can carry a style sheet with a prefers-color-scheme query and draw a dark mark on light tabs and a light one on dark tabs. Chrome has supported SVG favicons since version 80, Firefox for many years, and Safari since 26; keep a PNG or ICO fallback for the rest. The query again follows the browser's setting, not your page's switch. The other approach needs no query at all: draw the mark on a filled tile, as this site's favicon.svg does, a navy square with "134" in white, which reads on a light tab and a dark one alike.
accent-color
accent-color tints checkboxes, radio buttons, range sliders and progress bars with one property, and it composes well with schemes: give it a token, and the controls follow the token's light-dark() pair. The browser picks a checkmark color that contrasts with the accent. Page 03 gives its support story: Chrome 93 and Firefox 92 support it, MDN records full support only from Safari 26.2, and Chrome for Android does not keep the control's contrast legible, which is why webstatus.dev calls it limited. Treat it as polish over controls that work without it. Together with color-scheme, which makes the controls dark in the first place, it is usually all a form needs.
scrollbar-color, Baseline since December 2025, can tint scrollbars the same way, but color-scheme alone already gives dark scrollbars on a dark page, and most pages need nothing more.
10. Which is easier to read?
Dark mode is often described as easier on the eyes. The research is more specific than that, and it is worth knowing before arguing for a default. Raluca Budiu's review for the Nielsen Norman Group, "Dark Mode vs. Light Mode: Which Is Better?" (February 2020), did not run a new study; it summarized the published ones, and its findings remain a widely cited summary:
- For readers with normal vision, light mode usually wins. In the review's words, "in users with normal vision, light mode leads to better performance most of the time." Piepenbrock and colleagues (2013) found that dark text on a light background gave better visual acuity and proofreading in both young adults (18 to 33) and older adults (60 to 85), though the gap was smaller for the older group. The term in the literature is positive polarity, dark text on light.
- The advantage grows as text shrinks. The light-mode advantage was largest for small text.
- Lighting matters. In a 2017 study by Dobres, Chahine and Reimer, under simulated daytime lighting polarity made no significant difference, while at night light mode did better, and small text in dark mode was much harder to read.
- Some readers do better in dark. People with cloudy ocular media, most often cataracts, which are common in older people, read faster with light text on dark (Legge and colleagues, 1985), probably because a dark page sends less light into the eye to be scattered.
- People do not feel the difference. Participants in one of the Piepenbrock studies reported no difference in readability between the two, even though they performed better in light mode.
Its advice follows: do not switch to dark by default for a general audience, let readers switch if they want to, and offer dark mode especially in apps for long-form reading. That is the case for this page's design: default to the reader's setting, offer both, impose neither. It is also a case for measured design over taste. Neither scheme is better for every reader in every room, and the reader is the only one who knows which they need.
11. Telling the server: Sec-CH-Prefers-Color-Scheme
Everything so far runs in the browser. A server that renders different HTML per scheme, for example inlining only the dark style sheet or choosing a dark hero image, would need to know the scheme on the first request, before any CSS or script has run. The Sec-CH-Prefers-Color-Scheme client hint carries it. Like other client hints, the browser sends it only after the server asks:
Accept-CH asks for the hint on later requests; Vary tells caches to keep one copy per scheme; and Critical-CH asks the browser to retry the first request at once with the hint, so even the first page is right, at the cost of a second round trip on that first visit. It works only over HTTPS. MDN marks it experimental, and its compatibility data lists it in Chrome and Edge from version 93 and in neither Firefox nor Safari, so it remains Chromium only as of October 2026.
It is a high-entropy hint: one more bit about the reader that, combined with others, can help identify them, which is why the browser does not send it unasked and may decline to send it at all. Privacy covers the principle. For most sites the honest answer is that the server does not need it: color-scheme, the meta tag and light-dark() give every reader the right first paint from one cacheable response. The hint earns its place where the HTML itself must differ, and it belongs with the HTTP lesson's client hints; CSE 135 measures preferences like this one, in aggregate, never as profiles.
12. Summary
| Concept | Key points |
|---|---|
| Two palettes | Declare the schemes, default to the system setting, let the reader override it, and design both palettes on purpose. |
color-scheme | light dark on the root lets the browser draw the canvas, controls, scrollbars and system colors in the reader's scheme; light or dark forces one; only blocks automatic darkening. The meta tag declares it before CSS loads and prevents the white flash. |
prefers-color-scheme | Reports the system setting in CSS, <picture>, theme-color, SVG favicons and a request header. It never reports the page's own choice. |
matchMedia() | matches now, and a change listener for live switches such as sunset. Prefer CSS where CSS can do it. |
light-dark() | One token, two colors, chosen by the used scheme, so color-scheme on the root flips every token. Baseline since May 2024, widely available from mid-November 2026. Fall back with @supports not, not with a declaration before it. |
| This site | Role tokens are light-dark() pairs of oklch() primitives; Vanilla Breeze maps data-mode to color-scheme and follows the system by default. Navy surfaces step lighter as they rise; the primary flips from navy to gold. |
| Three-state switch | System, Light, Dark as a radio group. Store the choice in localStorage inside try; System removes it. Apply it from a classic script in the head, before the first paint. |
| Not inversion | filter: invert(1) turns brands and status colors into their opposites, photos into negatives and cards into black holes. The discolored store's SIN #1; the fixed store's COLOR FIX #1 is a second palette. |
| Designing dark | No pure black; elevation by lighter surfaces; lower chroma on large areas; a lighter brand variant; off-white text; every pair rechecked in both schemes. |
| Images | Never invert photos. SVG with currentColor and tokens follows everything; light-dark() images follow the switch (Baseline September 2026, behind @supports); <picture> follows only the system unless a script rewrites its media. |
| Browser chrome | theme-color with media is limited (Chrome for Android, installed apps elsewhere, not Firefox). SVG favicons can carry a query; a filled tile needs none. accent-color tints controls, as polish. |
| Research | NN/g (2020): light mode reads better for most people with normal vision, especially for small text and at night; dark may help readers with cataracts. Default to the system, offer both, impose neither. |
| Client hint | Sec-CH-Prefers-Color-Scheme, opted in with Accept-CH, Vary and Critical-CH: Chromium only, experimental, high-entropy. Most sites do not need it. |
