Skip to main content
Version: 0.13

Multi-axis walkthrough

Step-by-step setup of a multi-axis theming model — mode × brand × contrast — using the resolver input. The walkthrough builds it up in stages, starting with mode × brand and adding contrast at the end. The live Storybook runs the full three-axis fixture; open it in a second tab and cross-reference as you read.

1. The token layout

tokens/
color.json # palette primitives (color.palette.blue.500, …) + semantic roles (color.surface.default, color.text.accent, …)
size.json # size primitives (size.100, size.rem-400) + space.md + radius.sm
font.json # font.family.sans, font.weight.regular
typography.json # composites — typography.body, typography.heading
motion.json # duration.fast, cubicBezier.ease-out, transition.enter
border.json # composites — border.default, border.focus
shadow.json # composites — shadow.sm, shadow.md
gradient.json # gradient primitives
stroke.json # stroke.style.solid, stroke.style.dashed
number.json # unitless — number.opacity.disabled, number.line-height.tight
themes/
light.json # Light-mode role overrides
dark.json # Dark-mode role overrides
brand-a.json # Brand A accent overrides
contrast-high.json
resolver.json

One file per DTCG $type at the root — palette primitives and semantic roles coexist under their type root. For colors the palette hues live under color.palette.* (blue, neutral, red, green, yellow) while semantic roles sit at color.<role>.* (color.surface.default aliases {color.palette.neutral.0}, etc.). Modifier overlays under themes/ re-alias the role paths per context. Components alias the role paths directly; variation across mode, brand, contrast flows through the modifier overlays. See Terrazzo's style guide for the rationale.

2. The resolver

Two independent modifiers:

tokens/resolver.json
{
"$schema": "https://design-tokens.org/tr/2025/drafts/resolver/",
"version": "2025.10",
"sets": {
"tokens": { "sources": [{ "$ref": "./color.json" } /* , … */] }
},
"modifiers": {
"mode": {
"description": "Light/dark surface + text baseline.",
"contexts": {
"Light": [{ "$ref": "./themes/light.json" }],
"Dark": [{ "$ref": "./themes/dark.json" }]
},
"default": "Light"
},
"brand": {
"description": "Accent palette.",
"contexts": {
"Default": [],
"Brand A": [{ "$ref": "./themes/brand-a.json" }]
},
"default": "Default"
}
},
"resolutionOrder": [
{ "$ref": "#/sets/tokens" },
{ "$ref": "#/modifiers/mode" },
{ "$ref": "#/modifiers/brand" }
]
}

Key points:

  • "Default": [] is a legal context — no override files, just the baseline roles.
  • resolutionOrder lists modifiers after sets: the tokens set loads first, mode/brand overlay on top.
  • brand appears after mode in resolutionOrder, so if both touch the same token path, brand wins. That's authoring intent — brand-a.json can override light.json for accent-only tweaks.

3. Config

swatchbook.config.ts
import { defineSwatchbookConfig } from '@unpunnyfuns/swatchbook-core';

export default defineSwatchbookConfig({
tokens: ['tokens/**/*.json'],
resolver: 'tokens/resolver.json',
default: { mode: 'Light', brand: 'Default' },
cssVarPrefix: 'ds',
});

default is a partial tuple keyed by axis name. Omit it entirely to start in the all-axis-defaults tuple; name any axis explicitly to override. Unknown keys and invalid values produce warn diagnostics and fall back to the axis default.

4. What Storybook shows

With the config live:

  • Toolbar shows a single Swatchbook icon button. Click it to open a popover containing two dropdowns (one labelled mode, one labelled brand), preset pills (if configured), and a color-format picker. Each dropdown lists its contexts.
  • Design Tokens panel shows a diagnostics summary at the top and a hierarchical tree of every resolved token for the active tuple beneath it. Switching the toolbar updates values live.
  • CSS emission generates (prefix defaults to swatch; override via cssVarPrefix):
    • :root with the default tuple (Light · Default).
    • [data-ds-mode="Dark"][data-ds-brand="Default"] { … }
    • [data-ds-mode="Light"][data-ds-brand="Brand A"] { … }
    • [data-ds-mode="Dark"][data-ds-brand="Brand A"] { … }
  • Demo components in the reference Storybook (Button, Card, Input) read var(--ds-color-*) directly and repaint as you switch axes — this is the documentation affordance that lets authors verify tokens in every context, not a production theming contract.

5. Preset pills

Give common combinations a name:

swatchbook.config.ts
export default defineSwatchbookConfig({
// …as before…
presets: [
{ name: 'Default Light', axes: { mode: 'Light', brand: 'Default' } },
{ name: 'Brand A Dark', axes: { mode: 'Dark', brand: 'Brand A' } },
],
});

The toolbar popover now renders pills above the dropdowns. Clicking a pill writes the full tuple; the pill highlights when the current state matches.

6. A third axis

The fixture now has a contrast modifier too, composing additively with mode and brand for a total of 8 themes. The overlay only touches contrast-specific paths — borders, focus rings, a couple of text slots — so it composes cleanly with mode (which owns surfaces) and brand (which owns accents):

tokens/resolver.json
"contrast": {
"description": "Border + focus emphasis.",
"contexts": {
"Normal": [],
"High": [{ "$ref": "./themes/contrast-high.json" }]
},
"default": "Normal"
}

contrast is appended to resolutionOrder after brand, so brand's accent stays dominant and contrast only reinforces where they don't collide. The live Storybook includes an A11y High Contrast preset that flips contrast to High on the Light baseline — open the preview and try it alongside the other two presets. See axes on why the CSS emission is flat rather than nested.

See also