Skip to main content
Where the brand config declares what your brand means, component configs declare how each component behaves: its variants, interaction states, structure, and spatial rhythm. Every component in the engine is a directory under src/components/ with a fixed file layout:
The directory name is the component name. Two hand-authored files sit side by side and must not be conflated: config.yaml is the styling config this page documents; config.doc.yaml is prose metadata (description, anatomy, accessibility, guidelines). A third file shape, config.doc.gen.yaml, is a generated per-brand projection — derived, never edited.

The vocabulary

The engine defines these terms precisely, and two of them are counter-intuitive: A documentation page may describe fewer roles than the config declares — never one the config does not.

A complete config

The badge config, verbatim from the engine — small enough to read whole, and it exercises the core schema:
Three things to notice:
  • extends: types/action pulls in a shared archetype — roles, states, and semantic maps defined once in a type config. The value is a path (types/action), not a bare name.
  • states: null deletes. Badge inherits hover/pressed states from the action archetype, then removes them, because a badge is non-interactive. null is the only delete signal in the cascade — there is no separate “unset” keyword.
  • Relative deltas are quoted strings. Where a state nudges a channel, it authors lightness: "+0.05" — quoted so YAML preserves the sign and the value stays a delta rather than an absolute.
The top-level keys:

Channels: background, foreground, border

background, foreground, and border are channels, not ordinary properties. A channel accepts either a scalar shorthand or a block of channel keys — lightness, chroma, contrast, alpha, tint, gradient, filter, noise: foreground: { contrast: auto } means auto-contrast — black on light, white on dark — computed from the resolved background rather than the APCA solve.
Channel blocks are position-sensitive. A block is legal inside a role, part, or mode bag — and a hard error in the root style: bag or a breakpoint bag, where the emitter would treat it as a scalar and corrupt output. The validator refuses the config with exactly that explanation.

Spatial values are multipliers, not pixels

Spacing properties (padding-x, padding-y, gap-column, margins) are authored as unitless multipliers of the brand’s spatial system. padding-x: 2 generates:
You author the ratio; the engine emits the responsive calc() chain, with the resolved-at-defaults pixel value as a comment. Similarly, font-scale is a scale-step exponent, not a size: font-scale: -1 means one step below the base on the brand’s type ratio, computed as base × ratio^step (with a density-coupling term) — the same continuous model as everywhere else in Substrate. shape: full is the capsule shorthand, emitting border-radius: 9999px.

Parts and uses:

Parts declare the structure a component owns. The card component’s parts, from the engine:
A part can inherit its styling from a type-config role with uses:. The match is by part name against the type’s roles, with a suffix fallback — a part named emphasized-body with uses: types/text inherits the body role. An unmatched uses: is a hard error at generation, with a message that names the available roles and suggests the nearest one — the engine’s stated policy is converting silent no-ops (“the part would silently inherit nothing”) into loud failures.

The resolution cascade

A component’s final config is a deep merge across up to six layers, in a fixed order: base → type → brand type → sub-brand type → brand → sub-brand Brand overrides live either as a colocated brands/{brand}/config.yaml inside the component directory or in the brand’s own tree, and they are deltas only — a partial YAML containing exactly what changes, conventionally opened with a comment naming the brand, the component, and the design source. The merge rules are the same everywhere: Keys are normalized kebab-case → camelCase for the kernel, with one carve-out: named-resource keys (intents, materials, gradients, ramps) round-trip verbatim, so your intent named acme-cyan stays acme-cyan. A key collision after normalization throws rather than silently dropping a spelling.

Validation: the generator refuses bad configs

Validation runs at generation time, and the policy is strict: unknown property keys, invalid value shapes, misplaced channel blocks, and unresolvable gradient references are hard errors with actionable diagnostics — pathed to the exact key (parts.header.style.margin-y-top) and carrying “did you mean” suggestions:
Every shipped config is also covered by corpus tests that validate the entire component tree on every engine test run, and npm run build verifies generated output is current before compiling — stale output fails the build.

From config to CSS

The mapping to generated output is direct. For the badge config above, the generator emits generated/global/css/components/badge/badge.gen.css (marked DO NOT EDIT): Roles and modes both emit [data-mode~="…"] selectors from the same code path — at the CSS level, a role name and a mode name are just tokens in the same space-separated attribute. That is why the markup contract treats data-mode as one composable mode list. A dev-time helper additionally watches the DOM and warns on data-mode tokens no config defines.
One naming trap. The Surface component’s config declares the roles card, panel, and inset — a pure containment vocabulary with no extends: and an empty root style. The card component is a different artifact: it extends the feedback archetype and uses the conventional primary/secondary/auxiliary roles. When you see data-mode~="card", that is the surface role, not the card component.

Type Configs

The five shared archetypes components inherit via extends — including the text roles that drive per-role contrast.

Markup Opt-In

How the roles, states, and modes defined here are activated per element with data-mode.