Skip to main content
Substrate’s output looks like a familiar token file — CSS variables, Swift values, Kotlin values — but the architecture that produces it is fundamentally different from a static library. Substrate is a runtime solver, not a build-time token baker: the generation pipeline emits solver inputs and style descriptors, and a runtime kernel computes final values against the actual surface your content sits on. This page explains the ideas that underpin that design. Understanding them will help you author brand intent accurately, debug unexpected output, and extend the system when your needs grow beyond the defaults.

Computed vs. Static Tokens

In a traditional design system, a token stores a fixed value. color-primary-500 is #1A7F5A. That value was decided by a designer, written into a file, and consumed directly by every platform. The file is the source of truth. In Substrate, a token stores intent — a description of what a color, size, or duration should mean, not what it should be. The source of truth is your brand’s YAML config (config.yaml, or config.global.yaml for a brand family). The pipeline reads that intent and emits three kinds of artifacts:
  • Solver inputs — each intent’s hue and chroma, with lightness deliberately absent (it stays solver-owned).
  • Style descriptors — per-component styling rules expressed as data, interpreted by a runtime kernel on each platform.
  • Baked per-mode token files — static snapshots (light, dark, highContrast, darkHighContrast) for platforms and moments where the runtime hasn’t run yet.
The final values are computed at runtime by a kernel — JavaScript on the web, Swift and Kotlin ports on mobile — that solves APCA lightness against the actual surface, driven by a continuous preference vector with seven axes: scheme, contrastFactor, densityFactor, typeScaleFactor, motionFactor, warmth, and cvd. The same intent produces different values in different contexts — a higher-lightness surface near the light end of the scheme axis, a light-on-dark solved foreground near the dark end, elevated contrast when contrastFactor rises — all from the same single declaration. If your brand hue changes, every computed value updates across every mode and platform the next time you regenerate. Nothing drifts. On the web, the runtime writes the solved primitives as --ucs-* custom properties, and any element carrying data-ucs (plus a data-mode role such as brand or danger) participates in the cascade.
Never edit the generated output files directly — every one is marked .gen. and carries a DO NOT EDIT header. They are build artifacts. Changes belong in your brand’s YAML config.

Color Intent

Colors in Substrate are defined as intents rather than hex values. An intent describes a color’s perceptual character — its hue and chroma — without fixing its lightness. Lightness is computed by the contrast solver at every point on the scheme axis. Intents live in an open intents: map in the brand YAML; only brand and neutral are required, and a brand may declare any custom intents it needs. An example config.global.yaml for a fictional brand, Acme:
An intent’s hue and chroma can also vary across the scheme axis instead of being constant. The grammar is a scheme track: either the scheme-end shorthand (a second hue/chroma for the dark end) or a full scheme-track list of stops, each pinned at a continuous position at between 0 (light) and 1 (dark). A stop can even sample another intent at the same position via from-intent, and interpolation space is selectable with blend (oklch or oklab).
Letting chroma rise toward the dark end is a common technique for keeping a color’s perceived character consistent across luminance extremes, where colors tend to read as less saturated. Note that scheme tracks are an available capability, but none of the engine’s bundled demo brands currently uses one — they all author constant hue/chroma per intent.

Why OKLCH?

Substrate uses the OKLCH color space for all internal calculations. OKLCH is a polar form of the OKLab perceptual color space, with three axes:
  • L — Lightness (0–1), perceptually uniform
  • C — Chroma (0–~0.4), the intensity or colorfulness
  • H — Hue (0–360°), the color angle
OKLCH is perceptually uniform, meaning equal numeric steps produce equal perceived differences. This is critical for generating color ramps that look evenly stepped to the human eye — and it is what makes the contrast solver possible: the solver can search along the L axis for a target contrast without the hue or chroma drifting underneath it. Legacy color spaces like HSL produce steps that vary wildly in perceived lightness — a ramp from HSL yellow to HSL blue will have a jarring middle band regardless of how even the numeric spacing is.

APCA Contrast Solving

WCAG 2.x contrast ratios are based on relative luminance and a single formula: (L1 + 0.05) / (L2 + 0.05). This formula is known to produce misleading results for text on mid-tone backgrounds, thin typefaces, and small text sizes. It treats all contrast the same regardless of context. APCA (Accessible Perceptual Contrast Algorithm) replaces this model. APCA calculates a signed lightness contrast value — Lc — that accounts for polarity (is the text light-on-dark or dark-on-light?), spatial frequency (font size and weight), and the perceptual characteristics of the human visual system. APCA attribution and use restrictions: see About APCA. Substrate runs the APCA solve at runtime, per surface. The process works like this:
  1. Each intent’s scheme track is evaluated at the current scheme position, yielding its hue and chroma.
  2. Two user-compensation transforms run first — warmth, then cvd — so color is adjusted for the user before any contrast math.
  3. The solver takes the actual surface the content sits on (exposed to CSS as --ctx-surface-l) and binary-searches the OKLCH lightness that achieves the target Lc against it.
  4. The targets come from a fixed, named policy: foreground Lc 75, border Lc 50, focus ring Lc 60, each scaled by the user’s continuous contrastFactor (bounded per brand by flexibility.contrast).
  5. The solved values are written as --ucs-{intent}-{hue,chroma,fg-l,border-l,surface-l} primitives; the generated CSS composes final colors from them with calc().
The result is that you never hand-pick a foreground color. Every text color and icon color is derived, not authored. Failures are explicit, not silent. When a target can’t be met within gamut, the solver reports the shortfall as an unmetLc value rather than quietly passing, and the generation pipeline runs a build-failing accessibility gate over the whole brand corpus — output like Accessibility gate: FAIL (2 failures: 2 apca) blocks the build until the brand config is fixed.
The full solver policy — the fixed Lc targets, contrastFactor scaling, shortfall reporting, and the build-failing accessibility gate — is documented in the APCA Solver reference.

Modes

Substrate does not have modes in the traditional sense of parallel token sets. It has a continuous preference vector, and modes are named presets — convenience points on that continuum. The presets ship in the engine (SCHEME_PRESETS):
dimmed is the one that surprises people: it sits between the endpoints — lighter than dark, not darker — and is sampled continuously by the solver rather than being a recolor of either end. The per-preset walkthrough, including how contrastFactor moves every Lc target, lives on Modes Overview. Because these are presets over a continuum, users are not limited to them: scheme can sit anywhere in 0–1, and the other axes — densityFactor, typeScaleFactor, motionFactor, warmth, and cvd (type plus continuous severity) — adjust independently at runtime. On the web the runtime applies all of this live. For native platforms the pipeline additionally bakes static per-mode token files for light, dark, highContrast, and darkHighContrast; dimmed, being mid-track, has no baked file and requires the kernel’s continuous sampling.

Cascading Inheritance

Substrate is designed to support multi-brand systems where many brands share the same mathematical model. Inheritance is structural: a brand family directory contains a shared config.global.yaml, and each sub-brand directory contains a config.yaml holding only its deltas. An example sub-brand config for a fictional airline family, Aurora Airways, and its loyalty program (src/brands/aurora/rewards/config.yaml):
The two layers are combined by a recursive deep merge — partial objects merge field-by-field, so the Rewards sub-brand can override just intents.brand.chroma and inherit the hue. Setting a key to null deletes it from the merged result; arrays replace wholesale. Deltas-only authoring isn’t just a convention: the validator flags any sub-brand value that merely repeats its parent’s. The merged brand gets the slug aurora-rewards (parent–child), which is what appears in the runtime’s data-brand attribute. This cascading model means:
  • Updating the family’s shared config propagates to every sub-brand on the next generation.
  • Adding an intent in config.global.yaml gives every sub-brand that intent automatically.
  • A new sub-brand requires only a handful of lines to bootstrap a full, accessible token set — with the same solver guarantees as every other brand in the system.

Platform Output

Substrate emits the same brand model in each platform’s native idiom, under generated/ (globally shared artifacts in generated/global/, per-brand artifacts in generated/brands/<brand>/<sub-brand>/). The token family covers CSS, DTCG JSON, Swift, Kotlin/Compose, and React Native, with further targets (xcassets, JSON conformance vectors) alongside.
Two layers. The generated cascade composes everything from continuous scalars — this is real output from generated/global/css/tokens.gen.css and the badge component:
And per-mode baked token files provide the no-JS floor — the shape of generated/brands/<brand>/<sub-brand>/css/tokens.light.gen.css:
There are no numbered spacing or type-size ladders on any platform. Spacing is one --space-unit scaled by continuous --density and --scale; type sizes are powers of a computed ratio (--effective-ratio); motion is one --duration scaled by --motion-factor. The continuous model is the cross-platform contract — the kernels on iOS and Android compute with the same factors the web runtime writes to CSS.

Brand Config Overview

See the full YAML schema — the open intents map, elevation, typography, shape, motion, space, flexibility bounds, and mode/density presets.

Modes Overview

Learn how the preference vector drives modes as presets, and how the runtime, SSR floor, and baked artifacts fit together.