Skip to main content
Light and dark in Substrate are not two token sets. They are the two ends of a single continuous axis, scheme, that runs from 0 (light) to 1 (dark). Every position in between is valid and produces solved values, which is what makes dimmed (scheme: 0.65) a real mode rather than a hand-authored third palette. Because there is only one axis and one solve, the two ends cannot drift apart. There is no dark override file to forget to update.

What moves along the axis

Three things change as scheme moves from 0 to 1:
  1. Surface lightness. The base page surface is derived from the brand at the current position, and elevated surfaces are derived from that base.
  2. Intent hue and chroma, but only if the intent declares a scheme track. A plain intent keeps constant hue and chroma at every position; a tracked intent interpolates between its stops. Track stops are pinned at continuous positions, so evaluation at 0.65 is an interpolation, not a snap to an endpoint.
  3. APCA polarity. Near the light end, the solver searches for a dark foreground against a light surface. Near the dark end it searches for a light foreground. The target Lc is the same magnitude; the direction flips.
What does not change is the contrast policy. Foreground targets Lc 75, borders Lc 50, and focus rings Lc 60 at every scheme position — see High Contrast for the one axis that does scale those targets.
Lightness is never authored. An intent declares hue and chroma; the solver owns L. That is why moving along the scheme axis cannot break contrast — each position gets its own binary search for a lightness that hits the target against the surface actually in play.

Moving along the axis

Set scheme and re-sync. There is no attribute to toggle:
A toggle is just a two-value special case of a continuous control:
To follow the operating system instead of an in-app control, read the media query and write the axis:

What the runtime writes

syncPrefsToCssVars writes the raw axis values to the root element, including --scheme itself, so CSS that composes from continuous scalars reacts immediately. updateAllVars runs the APCA solve and writes the per-intent primitives — five numeric properties per intent:
These are primitives, not finished colors. The generated CSS composes oklch() values from them with calc(), which is what lets a single set of numbers drive an entire cascade. An element selects which intent’s primitives to use through its data-mode role:

Baked light and dark artifacts

Alongside the runtime path, the pipeline bakes static per-mode files for the moments before JavaScript runs and for native platforms. These hold resolved hex values rather than primitives — from generated/brands/acme/css/tokens.light.gen.css:
Each brand gets tokens.light.gen.css, tokens.dark.gen.css, tokens.highContrast.gen.css, and tokens.darkHighContrast.gen.css. There is no tokens.dimmed.gen.css — a mid-track position has no endpoint to bake, so dimmed exists only through the runtime or a native kernel port. On top of those, each brand’s CSS carries the no-JS snapshot: the same four presets emitted on [data-brand="<slug>"] under prefers-color-scheme and prefers-contrast media queries. That snapshot covers the page surface at first paint; scoped surfaces and continuous positions need the runtime. See Modes Overview.

Authoring intent across the axis

Most intents need nothing beyond a hue and chroma — the solver handles both ends. When a color needs to shift character between the endpoints, declare a scheme track in the brand YAML:
scheme-end is the shorthand for a two-stop track; a full scheme-track list gives you stops at arbitrary positions. Letting chroma rise toward the dark end is a common way to keep a color’s perceived character stable, since colors read as less saturated against dark surfaces. Full grammar in Core Concepts and the Color config reference.

Modes Overview

The full seven-axis preference vector and the five named presets.

High Contrast

The contrast axis that scales the Lc policy independently of scheme.