Skip to content

The web (CSS user-preference media features)

The platform that turned every other platform's preferences into a declarative, sandboxed, vendor-neutral vocabulary — and the one hue's HTML sink emits into.

Preference surfaceprefers-color-scheme · prefers-contrast · prefers-reduced-motion · prefers-reduced-transparency · forced-colors · inverted-colors
Canonical APICSS media features; window.matchMedia() in script
Change notificationpush — the media query re-evaluates and restyles with no script at all
Palette derivationnone; but color-scheme and light-dark() remove most of the need for it
Accent colornot exposed — deliberately, for fingerprinting reasons
Relevance to huethe --html sink and the planned multi-theme output (DEF5)

Overview

What it solves

A web page is the most constrained consumer in this survey: it cannot read a registry, call a D-Bus method, or link a toolkit. The platform's answer was to expose preferences as media features — the same mechanism already used for viewport width — so that adapting to dark mode requires no script, no permission, and no API call, and so that the browser can re-evaluate and restyle without the page participating.

Design philosophy

Three properties of the design are worth naming, because they are the ones a native toolkit can learn from:

  1. Preferences are queries, not values. A page never reads "the user prefers dark"; it declares what to do if they do. The absence of a getter is what makes the change path free — there is no cached value to invalidate.
  2. The set is deliberately incomplete. There is no accent-color media feature, and the CSS Working Group has discussed and repeatedly declined exposing the system accent, because a free-form color read by any page is a high-entropy fingerprinting vector. Every native platform in this survey exposes the accent freely; the web is the one place where the privacy cost was priced in and the feature was cut. That asymmetry is worth remembering when hue --html emits a document: an accent the native app followed cannot be re-derived by the page, so it must be baked into the emitted CSS.
  3. Two-way negotiation via color-scheme. The color-scheme property is not a query but a declaration: :root { color-scheme: light dark; } tells the browser the page handles both, which in turn makes the UA restyle form controls, scrollbars and the canvas background to match. A page that styles dark mode but omits color-scheme gets dark content with light scrollbars — the web's version of Windows' light title bar on a dark app.

How it works

The feature set

FeatureValuesNative counterpart
prefers-color-schemelight · darkevery platform's color scheme
prefers-contrastno-preference · more · less · customGNOME contrast, Apple accessibilityContrast, Android 14
prefers-reduced-motionno-preference · reduceportal reduced-motion, Apple reduce motion
prefers-reduced-transparencyno-preference · reduceApple reduce transparency, Windows transparency effects
forced-colorsnone · activeWindows High Contrast (windows)
inverted-colorsnone · invertedplatform color inversion

Note that prefers-color-scheme has two values, not three: the spec folds "no preference" into light. This is the one place the web is less expressive than the native platforms, and it is a deliberate simplification — a page's default styles serve the no-preference case, so the third value would have no distinct behaviour to select.

prefers-contrast: less has no counterpart anywhere else in the survey.

light-dark()

The modern form collapses the two-block pattern into one declaration:

css
:root {
  color-scheme: light dark;
  --page-bg: light-dark(#fdf6e3, #1e1e2e);
  --page-fg: light-dark(#4c4f69, #cdd6f4);
}

light-dark(a, b) picks by the element's used color-scheme, which means a subtree that declares color-scheme: dark gets the dark value regardless of the user preference — the same per-subtree override iOS traits and NSAppearance provide, expressed in CSS.

For hue --html this is directly the mechanism DEF5 calls for: one document carrying both palettes, switched by :root[data-theme] or prefers-color-scheme, rather than two emitted files. Emitting light-dark() pairs into the existing --twoslash-* custom-property block that sparkles.ui.style.writeTwoslashVars already generates is a small change to a generator that exists.

forced-colors

When active, the UA replaces the page's colors with the user's set. The rules the page must follow are inverted from every other mode: remove color rather than adapt it, keep forced-color-adjust: auto (the default) so the substitution happens, and use the system color keywords (Canvas, CanvasText, LinkText, ButtonFace, Highlight) for anything that must remain distinguishable.

An HTML sink that hard-codes syntax-highlight colors — which is exactly what a syntax highlighter does — will have them all substituted to CanvasText under forced colors, collapsing the highlighting to plain text. That is the correct outcome, and worth stating so it is not later filed as a bug.

Change notification

The purest push in the survey: the media query re-evaluates and the page restyles, with no script running. When script needs to know:

js
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
  /* e.matches */
});

which is the only place in this survey where the notification carries the value reliably, because the value is a boolean the UA already computed.

Reachability

Total, and free. This is the one platform where following the system requires no capability detection, no fallback chain and no timeout — the styles simply apply. The cost is the ceiling: a page cannot learn the accent, cannot learn the exact system colors outside forced-colors mode, and cannot distinguish "no preference" from "light".

Traps

TrapConsequence
Styling dark mode without declaring color-schemedark content with light scrollbars and form controls
Expecting a third no-preference valuethere is none; the default styles are the no-preference case
Treating forced-colors as "high contrast"the app should stop specifying color, not push it further apart
Overriding forced-color-adjust to keep brand colorsdefeats the accessibility mode users rely on
Assuming syntax highlighting survives forced colorsit does not, correctly
Looking for a system accentnot exposed, deliberately — bake it in at generation time
Toggling a data-theme attribute without updating color-schemeUA-painted surfaces stay on the old scheme

Strengths

  • Zero-cost change handling: no script, no listener, no invalidation.
  • The broadest preference vocabulary in the survey, including prefers-contrast: less and inverted-colors, which no native platform here exposes.
  • light-dark() plus color-scheme gives per-subtree override with one property.
  • A real forced-colors contract with system color keywords to target.

Weaknesses

  • No accent color, by design.
  • Two-valued color scheme, so "no preference" is unrepresentable.
  • No way to read the platform's actual colors, so a page can match the mode but never the palette the way KDE consumers can.
  • Forced colors silently destroys semantic color, including syntax highlighting.

Key design decisions and trade-offs

DecisionRationaleTrade-off
Expose preferences as media queries, not gettersrestyling is automatic; no cached value to invalidatescript that genuinely needs the value takes a second, parallel path
Do not expose the accent colora free-form user color is a fingerprinting vectorthe web cannot match native chrome; generators must bake the accent in
Fold "no preference" into lightthe page's default styles already serve that casethe three-valued native state cannot round-trip through a page
color-scheme as a declaration, not a querylets the UA restyle its own painted surfaces to matchforgetting it produces the half-themed look, and nothing warns you
forced-colors replaces rather than adjustsguarantees a usable result regardless of the pagedestroys legitimately semantic color, e.g. syntax highlighting

Sources