Skip to main content
Substrate treats color as intent, not as a palette. You never author a hex value, and you never author a lightness. You declare what a color’s perceptual character is — its hue and its chroma — and the APCA solver computes the lightness that achieves the target contrast against whatever surface the color actually lands on, at whatever position the user’s scheme slider sits. APCA attribution and use restrictions: see About APCA.

The intents map

Colors live under a top-level intents: key. It is an open map: only brand and neutral are required, and everything else is the brand’s own decision. Each intent carries two OKLCH parameters:
  • hue — the color angle, 0 to 360
  • chroma — colorfulness, 0 (achromatic) to roughly 0.4, with the real ceiling depending on hue and display gamut
An example foundation config for a fictional brand, Acme (src/brands/acme/config.global.yaml):
Intent names are your identifiers, so they round-trip verbatim — the kebab-case-to-camelCase normalization described in the config overview does not touch the direct children of intents. acme-cyan stays acme-cyan.

Conventional names, not reserved ones

There is no built-in-versus-custom split in Substrate. The pipeline reads whatever intents a brand declares; it never consults a fixed list to decide what a brand “should” have. Custom intents produce the same solver primitives, the same baked per-mode tokens, and the same platform output as any other. What does exist is a convention. Across the engine’s bundled demo brands, these names recur and carry consistent meaning:
Two kinds of naming apply here. brand and neutral are contractual — the loader refuses a config without them (and brand is the identity intent; there is no primary). Every other name is convention: the bundled demo configs and these docs use danger, but your brand may call its destructive intent error or destructive and get identical solver treatment. What actually breaks expectations is assuming a conventional intent exists without declaring it — nothing ships an error (or even a danger) intent by default; your intents: map is the only source of truth for which intents exist.
Brands lean on custom intents heavily. A fictional Aurora Airways family might declare aurora-navy and aurora-burgundy at the family level, while its Rewards sub-brand adds loyalty-tier intents like tier-gold, tier-silver, tier-platinum, and tier-accent — each getting the full solver treatment.

Intents that vary across the scheme axis

An intent’s hue and chroma can be a function of the scheme position rather than a constant — useful when a color’s perceived character drifts at luminance extremes and you want to compensate. The grammar has two tiers.
The shorthand: give a second hue and chroma for the dark end. It desugars to stops at positions 0 and 1.
These three grammar keys keep their kebab spelling everywhere, because the kernel’s track evaluator consumes them verbatim. Scheme tracks are a supported capability that none of the engine’s bundled demo brands currently uses — they all author constant hue and chroma per intent.

Lightness: the one exception

Lightness never appears in an intent, at any tier. The solver owns it at every scheme position, which is what lets a single declaration produce correct foregrounds on light surfaces, dark surfaces, and every point between. The exception is gradients. A gradient stop references an intent by name and does pin a lightness, because a gradient is a designed artifact rather than a contrast-solved one:

What each intent produces

For every declared intent, the runtime writes a set of --ucs-* solver primitives that downstream CSS composes with calc():
  • --ucs-{intent}-hue and --ucs-{intent}-chroma — the evaluated character at the current scheme position, after the warmth and CVD user-compensation transforms
  • --ucs-{intent}-fg-l, --ucs-{intent}-border-l, --ucs-{intent}-surface-l — solved lightness for each channel against the actual surface
  • --ucs-{intent}-pattern — the non-color redundancy channel
The generated brand CSS registers these with @property so they animate and inherit predictably. The shape of a brand’s generated css/brand.gen.css, here for a custom aurora-navy intent:
Alongside the live primitives, the pipeline bakes a resolved surface/text/border triptych per intent per mode, as the no-JavaScript floor. The shape of generated/brands/<brand>/<sub-brand>/css/tokens.light.gen.css:
Selectors are [data-brand="aurora-rewards"] for brand scoping and [data-mode~="brand"] for intent selection on an element carrying data-ucs. Substrate emits no theme attribute and no per-color variable family of its own — the --ucs-* primitives above are the whole vocabulary.
Because lightness is solved rather than authored, adding a mode or shifting the scheme slider requires no new color declarations. If a color reads wrong in dark mode, the fix is almost always the intent’s chroma — or a scheme-end — rather than a per-mode override.

APCA Contrast Solving

How the solver searches the lightness axis, and the fixed Lc policy it targets.

Brand Config Overview

The rest of the schema: elevation, typography, shape, motion, space, flexibility, and presets.