Skip to content
Abhishek Sawant

Frontend9 Min Read

Theming Is Three Separate Questions

light-dark(), contrast-color() and style queries each answer a different question about a component's surroundings, and picking the wrong one is how themes break.

FIG_01 // FRONTEND
Fig. 01 — Theming Is Three Separate Questions

Drop a pricing teaser into the footer band, the one strip of the site that is dark in both themes, and its text vanishes. Dark ink on a dark ground. The component has a perfectly good dark mode, and it’s no help, because the page is in light mode.

The component had asked the wrong question. Modern CSS now has three separate tools for theming, light-dark(), contrast-color() and style queries, and each one answers a different question about where a component is sitting. By the end of this post you’ll know which question each answers, where each one quietly stops working, and how to combine them without a single theme class on the component.

One Page, Three Questions

Here is the version most of us wrote first. The card reads colours from custom properties, and a media query swaps them when the operating system prefers dark.

card.css css
.card {
  background: var(--bg);
  color: var(--text);
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0d0b1e;
    --text: #fffdf8;
  }
}

That works until a component lands somewhere the page’s preference doesn’t describe. A media query answers a question about the page: what does the visitor’s system want? It cannot answer a question about the neighbourhood, such as a section that is always dark, a banner painted in a brand colour, or a card nested in another card.

Those neighbourhood questions come in three kinds, and they don’t have the same answer.

  • Which scheme am I in, light or dark, so I can pick my values?
  • Given the exact background I ended up on, which text colour is readable?
  • What kind of surface am I on, so I can change my shape and not only my colours?

Fig. 02 — One card, two neighbourhoods. The card has to notice the difference itself.

Light-Dark Reads The Scheme

light-dark() takes two colours and returns the first when the element’s used colour scheme is light and the second when it is dark. It is a plain function, usable anywhere a colour is, and it is supported in every current browser (Baseline since mid-2024).

The important word is scheme. light-dark() does nothing on its own, because an element’s scheme comes from the color-scheme property, and that defaults to normal, which means light. If you write a token with light-dark() and never set color-scheme, you get the first colour forever and wonder why dark mode is broken.

It also doesn’t know about your theme toggle. A data-theme attribute is an ordinary attribute to the browser, so you wire it to color-scheme once, at the root.

theme.css css
:root {
  color-scheme: light dark; /* follow the system by default */
  --bg: light-dark(#fffdf8, #0d0b1e);
  --text: light-dark(#0d0b1e, #fffdf8);
}

:root[data-theme="light"] {
  color-scheme: light;
}

:root[data-theme="dark"] {
  color-scheme: dark;
}

Every token now holds both values, and the color-scheme line decides which one is live. The toggle script touches one attribute and nothing else. A pleasant side effect is that color-scheme also darkens scrollbars and form controls, which a hand-rolled variable swap never reached.

Resolved Where It’s Used

Here is the property that fixes the footer band. A custom property that holds light-dark() is not resolved where you declare it. Unregistered custom properties are substituted as raw tokens, so the function is evaluated at the element that uses the variable, with that element’s colour scheme.

That means a subtree can opt into a different scheme and every token underneath follows.

band.css css
.band {
  color-scheme: dark; /* always dark, in both themes */
  background: var(--bg);
  color: var(--text);
}

Inside .band, var(--bg) yields the dark colour even when the page is in light mode, because the band’s own color-scheme is dark. The pricing teaser needs no change at all. It reads --text, the band says which scheme --text should be evaluated in, and the ink turns pale.

The trap is registration. If you declare the token with @property and a <color> syntax, the browser computes it once, where it is declared, and descendants inherit the finished colour.

Stays light

A registered token. It computes on the root, so the dark band inherits a colour that was already decided.

Flips

An unregistered token. The function stays unevaluated until the element that uses it, so the band’s scheme applies.

Registration is how you animate a colour token, so it is a reasonable thing to want. If you do it, declare the token on the elements that need it, or accept that scoped schemes will not reach through. Neither is wrong. It is only a surprise the first time.

Contrast-Color Picks Black Or White

A scheme isn’t the only thing that decides readability. Sometimes the background is a colour you don’t control, such as a user-chosen tag colour, a CMS accent, or a brand banner. contrast-color() takes a colour and returns either black or white, whichever contrasts more with it.

chip.css css
.chip {
  --chip: var(--accent);
  background: var(--chip);
  color: #0d0b1e; /* fallback */
}

@supports (color: contrast-color(red)) {
  .chip {
    color: contrast-color(var(--chip));
  }
}

Three details are worth knowing before you rely on it.

First, it returns only black or white. Not your brand’s near-black, not an off-white. If your system’s text colour is a tinted night, contrast-color() will hand you plain #000 on a yellow chip, and that is a design decision you’ve just delegated.

Second, the pick uses the WCAG 2 contrast ratio. Arithmetic puts the worst case for that choice at about 4.58 to 1, on a mid-grey, so the winner always clears the 4.5 line for body text. That is a floor and not a taste test. On saturated mid-tones, the colour WCAG 2 picks is sometimes not the one a person would pick.

Third, it can only judge the colour you hand it. A gradient or an image behind the text is invisible to it, and so is any overlay you add later. It also says nothing about borders, icons or focus rings, which need contrast with their neighbours too.

Support is recent: Safari 26, Firefox 146 and Chrome 147, so it reached all three engines in the last year (check support before you drop the @supports fallback). I’d keep the fallback for a while regardless.

Style Queries Ask The Parent

light-dark() and contrast-color() both produce colours. Neither can change a border width, swap a layout, or hide a decoration, because they live inside a single declaration. For structural change you need a query, and the one that fits is a style query.

surface.css css
.band {
  --surface: dark;
  color-scheme: dark;
}

.card {
  border: 3px solid var(--text);
}

@container style(--surface: dark) {
  .card {
    box-shadow: 6px 6px 0 var(--text);
  }
}

The card now grows a hard offset shadow whenever its surroundings declare --surface: dark. The card doesn’t know about the band, the band doesn’t know about the card, and neither has a modifier class.

Two rules explain every confusing case. A style query asks about an ancestor, so an element can never match its own style() condition, only its descendants can. And it compares the container’s computed value of a custom property, which is why inheritance does the work: --surface is set once on the band and every descendant sees it.

A theme is a value. A surface is a place.

Theming_Doctrine // §2

Notice --surface and color-scheme sitting side by side in the band. That is deliberate. A style query can currently test custom properties only, not color-scheme itself or any other real property, so you mirror the scheme in a variable you can query. It’s one extra line for each surface, and it keeps the two in step.

Support for querying custom properties is Chromium since version 111 and Safari since 18. Firefox is the one to check before you ship. The newer range syntax, such as style(--density > 2), is Chromium only as far as I can tell, so treat it as an enhancement. These queries are the same machinery as size container queries, minus the containment: any element can be a style container, with no container-type to set.

Match The Tool To The Question

Once the three questions are separate, the choice is mechanical.

QuestionReach forIt fails quietly when
Which values for this scheme?light-dark()color-scheme was never set
Which text colour on this ground?contrast-color()The ground is a gradient or an image
What shape on this kind of surface?Style queryYou query a real property instead of a variable

On Monday, I’d do this in order:

  1. Put color-scheme: light dark on the root, and map your theme toggle to it.
  2. Rewrite each colour token as a light-dark() pair, and leave the tokens unregistered unless you need to animate them.
  3. Give every always-dark or always-branded region its own color-scheme and a --surface variable.
  4. Use contrast-color() only where the background is a value you don’t author, with a fallback.
  5. Move any per-context layout rule into a style query on --surface.

None of this removes the need to look at the result. It does move the decision to the place that has the facts. The footer band knows it is dark, and now the card finds out by asking the band.

Tagged
  • CSS
  • Design_Systems
  • Accessibility

Related Logs

System Updates

Weekly dispatches,
straight to your terminal.