Back to engineering

Engineering

One app, two theme planes: where color-scheme leaks

The Bookatu engineering team7 min read

Our platform shell has its own light and dark theme, and every storefront has an independent one. The custom property tokens were scoped correctly, yet native checkboxes still flipped with the wrong theme. The culprit was color-scheme, a property that inherits from the root like any other, and it bit us a second time across an iframe boundary.

The short version

  • Custom properties do not theme native controls. Checkboxes, radios, select menus and date pickers follow color-scheme, which inherits from the root like any other CSS property.
  • If a scoped theme sets its tokens but never sets color-scheme, its native controls follow whatever the root says. That is how a light storefront ends up with dark checkboxes.
  • An iframe with a transparent body composites onto an opaque canvas when its color-scheme disagrees with the iframe element in the host page. The visible symptom is black wedges in the corners.
  • Rule of thumb: every independent theme plane declares its own color-scheme, in the same selectors where it declares its tokens.

Our app has two themes that have nothing to do with each other. The platform shell, where an owner manages their calendar and settings, follows that person's own light or dark preference. Each storefront, the public page where clients book, has a look the business picks for itself. A spa can run a warm light storefront while its owner works in a dark admin, and both are served from the same CSS bundle on the same domain.

We were careful about the part everyone is careful about: the tokens. The shell reads its colors from custom properties set on the root element, keyed off a data attribute that the theme toggle flips. The storefront wrapper sets its own properties, keyed off its own attribute, and nothing inside the wrapper reads the shell's values. Backgrounds, text, borders and buttons all switch correctly, each plane on its own schedule. We shipped it and moved on.

Then a report arrived with a screenshot we could not argue with. A light storefront, rendered exactly as designed, with dark checkboxes and a dark date picker sitting in the middle of the booking form. The visitor's device was set to dark mode, so the platform shell was in dark mode too. The storefront was ignoring that, as designed. Its native controls were not.

Custom properties do not theme native controls

The browser paints some things itself. Checkboxes, radios, select menus, date pickers, scrollbars and the document canvas all have built-in rendering that a stylesheet only partly controls. The property that decides whether those built-ins render light or dark is color-scheme. It is a normal inherited CSS property. Declare color-scheme: dark on the root and every native control in the document renders dark, unless some subtree pins a value of its own.

Our root did exactly that. The shell's dark mode set color-scheme: dark alongside its tokens, which is the right thing for the shell. The storefront scope set around forty tokens and zero color-scheme declarations. So its checkboxes inherited the value from the root, straight through the wrapper, and flipped with a theme the storefront had otherwise fully escaped. The tokens were airtight. The inheritance was not.

css
/* Plane 1: the platform shell, themed from the root. */
:root {
  --surface: #ffffff;
  --ink: #1b1b1f;
  color-scheme: light;
}
:root[data-app-theme="dark"] {
  --surface: #17171b;
  --ink: #f2f2f4;
  color-scheme: dark;
}

/* Plane 2: the storefront. The tokens were always scoped
   here. The one-line fix is that color-scheme now is too. */
.shop-scope {
  --surface: var(--shop-surface, #ffffff);
  --ink: var(--shop-ink, #1b1b1f);
  color-scheme: light;
}
.shop-scope[data-shop-theme="dark"] {
  color-scheme: dark;
}

That is the entire fix for the first bug. The storefront pins color-scheme in the same selectors where it sets its tokens, so the value the browser sees inside the wrapper comes from the storefront's own theme choice and never from the shell's. The two planes stopped sharing the one property we had forgotten was shared.

The same property, one document over

A while later the same property got us again, in a place where inheritance cannot even reach. We offer an embeddable booking widget, a small iframe that a business drops into its own website. The widget's body is transparent so the host page shows through around the rounded corners of the card inside. On most sites this looked right. On a handful, the corners of the iframe were solid black wedges.

There is no CSS inheritance across an iframe boundary, so this could not be the same leak. It is a sibling of it. When the color-scheme used by the embedded document differs from the color-scheme of the iframe element in the host page, browsers refuse to composite the frame transparently. They give it an opaque canvas instead, and the canvas for a dark scheme is black. The rule exists for a good reason: a dark document blended over a light page could come out unreadable, so the browser opts out of blending entirely when the two sides disagree.

The disagreement is easy to produce by accident. Our widget set its own scheme from the theme the business picked. The host page usually declared nothing, so the iframe element's scheme followed the visitor's system preference. A dark widget on an undeclared host with a light system, or the reverse, and the transparent body quietly became an opaque one. The fix is to pin both sides of the boundary to the same value on purpose.

js
// The embed script sets BOTH sides of the boundary to the
// same value, so the browser will composite transparently.
const scheme = widgetTheme === "dark" ? "dark" : "light";

const frame = document.createElement("iframe");
frame.src = widgetOrigin + "/menu?scheme=" + scheme;
frame.style.colorScheme = scheme; // host side of the boundary
css
/* Inside the widget document: the same value again,
   read from the query parameter the embed script sent. */
:root[data-scheme="light"] { color-scheme: light; }
:root[data-scheme="dark"]  { color-scheme: dark; }

Declare it where you declare your tokens

The rule we took away is short. Every independent theme plane must declare color-scheme, in the same place and with the same specificity as it declares its tokens. If a subtree owns its own idea of light and dark, it owns color-scheme too. If a document is embedded in a page it does not control, the two sides of that boundary have to agree, and agreement has to be arranged rather than assumed.

  • A scoped theme without its own color-scheme is incomplete, even when every visible pixel looks right in your own test setup.
  • Test theme planes in mixed pairs. Light shell with dark scope, dark shell with light scope. The bugs live in the combinations, and a developer who keeps everything dark will never meet them.
  • Put at least one native control on the page you screenshot. Tokens can be perfect while every checkbox is wrong.
  • For iframes with transparent backgrounds, set color-scheme on the iframe element and inside the embedded document, driven from the same source of truth.

Neither fix took more than a few lines, and finding them took far longer than writing them. Part of that is that color-scheme is invisible in the places we normally look. It rarely appears in the styles you think to inspect, and the default value works well enough in a single-theme app that you can ship for a long time without learning the property exists. If your app has more than one theme plane, it is worth five minutes today to check that each plane declares it.

cssdark modethemingiframesengineering

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs