Config reference
Every field on the Config object, with the semantics loadProject applies at runtime.
Two ways to supply it
Inline in .storybook/main.ts (recommended):
import { defineMain } from '@storybook/react-vite/node';
export default defineMain({
framework: '@storybook/react-vite',
addons: [
{
name: '@unpunnyfuns/swatchbook-addon',
options: {
config: {
resolver: 'tokens/resolver.json',
default: { mode: 'Dark', brand: 'Brand A' },
cssVarPrefix: 'ds',
presets: [
{ name: 'A11y High Contrast', axes: { mode: 'Light', contrast: 'High' } },
],
},
},
},
],
});
Or in its own file, referenced by path:
import { defineSwatchbookConfig } from '@unpunnyfuns/swatchbook-core';
export default defineSwatchbookConfig({
resolver: 'tokens/resolver.json',
default: { mode: 'Dark', brand: 'Brand A' },
cssVarPrefix: 'ds',
});
{
name: '@unpunnyfuns/swatchbook-addon',
options: { configPath: '../swatchbook.config.ts' },
}
The separate file path (configPath) is useful when the same config is consumed by other tooling: a CLI, a CI lint job, a custom build script. For Storybook-only projects, inline is one less file to track. defineSwatchbookConfig is an identity helper; it gives you typed autocomplete on the object you pass in and returns it unchanged.
Picking a theming input
Config has three valid shapes: ResolverConfig | LayeredConfig | PlainConfig. Which load-strategy field is present determines the shape, and invalid combinations (like { resolver, axes } or { axes } without tokens) are compile-time errors. The runtime also validates the same constraints for plain-JS callers.
Exactly three shapes are legal:
- Resolver (
ResolverConfig):resolver: 'path/to/resolver.json'. The DTCG-spec-native path; the file declares sets, modifiers, and resolutionOrder, and the modifiers become your axes. Recommended. - Layered (
LayeredConfig):axes: [...]+tokens: [...]. For projects without a resolver document. You declare axes inline; each context names overlay files that layer onto the basetokens. - Plain parse (
PlainConfig):tokens: [...]alone. Single syntheticthemeaxis; every token loaded as one tuple. Useful for smoke-testing a token set before adopting a resolver.
All three variants extend a shared base (cssVarPrefix, presets, disabledAxes, chrome, cssOptions, listingOptions, terrazzoPlugins, maxJointArity, default, outDir).
Fields
tokens?: string[]
Glob patterns (relative to the config's cwd) for base DTCG token files.
- Resolver projects (
resolverset): optional. The resolver's own$reftargets fully determine which files get loaded, and the addon's Vite plugin derives HMR watch paths from the resolved source list. Supplyingtokensalongsideresolveris still valid and unions the watch paths, useful when you want HMR to pick up files the resolver doesn't reference directly. - Layered projects (
axesset): required. The layered loader parses[...base, ...overlayFilesInAxisOrder]per tuple; withouttokensthere's nothing to layer onto. - Plain-parse (no resolver, no axes): required. Every file matched by the globs gets parsed into the single synthetic theme.
resolver?: string
Path to a DTCG 2025.10 resolver document. Mutually exclusive with axes.
Terrazzo normalizes the document, then Swatchbook enumerates individual tuples (one per non-default (axis, context)) and realizes each via resolver.apply(). One Axis surfaces per modifier; the resolver file itself and every $ref target it pulls in land on Project.sourceFiles.
axes?: AxisConfig[]
Authored layered axes, for projects without a resolver. Mutually exclusive with resolver. Each AxisConfig has:
interface AxisConfig {
name: string;
description?: string;
contexts: Record<string, string[]>;
default: string;
}
contexts is a map from context name to an ordered list of overlay files (paths or globs, relative to the config's cwd). An empty array is legal; it means "no override," which is the baseline for an axis that introduces nothing by default (Default: []).
The loader materializes one entry per non-default context per axis, never every combination at once. Last write wins on duplicate token paths across base + overlays; overlay files listed later in axes win over earlier ones.
default?: Partial<Record<string, string>>
Initial active tuple, keyed by axis name. Partial is fine; any axis you omit falls back to that axis's own default at load time. Unknown axis keys and invalid context values produce warn diagnostics (group swatchbook/default) and are sanitized out.
// Every axis specified:
default: { mode: 'Dark', brand: 'Brand A' }
// Partial — omitted axes use their own defaults:
default: { brand: 'Brand A' }
// Omit entirely — starts in the all-axis-defaults tuple:
// default: undefined
cssVarPrefix?: string
Prefix prepended to every emitted CSS variable. color.accent.bg becomes var(--<prefix>-color-accent-bg); an empty string disables the prefix. Typical values are short and project-specific (ds, sb, ui).
outDir?: string
Project-local directory for codegen artifacts (default .swatchbook). The addon preset writes tokens.d.ts here; add "include": [".swatchbook/**/*.d.ts"] to your tsconfig so useToken() autocompletes.
presets?: Preset[]
Named tuple combinations rendered as quick-select pills next to the toolbar's axis dropdowns. Each Preset has:
interface Preset {
name: string;
axes: Partial<Record<string, string>>;
description?: string;
}
Partial tuples are legal; omitted axes resolve to the axis's default when the preset is applied. loadProject validates presets: unknown axis keys and invalid context values surface as warn diagnostics (group swatchbook/presets) and are sanitized out, but the preset itself stays in Project.presets (an empty preset is still a valid tuple, resolving to all defaults).
disabledAxes
Type: string[], optional.
Axis names to suppress from this Storybook session. Each listed axis is pinned to its default context: the toolbar dropdown disappears, the axis drops out of Project.axes, non-default tuples fold away, and CSS emission stops including the axis in compound selectors. The Design Tokens panel still shows a small pinned indicator so authors don't forget the modifier is being suppressed.
defineSwatchbookConfig({
resolver: 'tokens/resolver.json',
disabledAxes: ['contrast'],
cssVarPrefix: 'ds',
});
Config-level only; there's no runtime toggle. Disabling an axis is the cleanest way to hide a work-in-progress modifier (or an experimental axis you don't want surfaced yet) without editing the resolver document. Unknown axis names produce warn diagnostics (group swatchbook/disabled-axes) and are ignored; the filtered list lands on Project.disabledAxes for downstream tooling.
chrome
Type: Record<string, string>, optional.
Override one or more of swatchbook's ten block-chrome roles. Block components (<TokenTable>, <ColorPalette>, etc.) read a fixed --swatchbook-* CSS variable namespace for their surfaces, text, and accents, independent of cssVarPrefix, so your token namespace never collides with chrome reads. Every role is always declared: by default, to the hard-coded literals in DEFAULT_CHROME_MAP (which use light-dark() so chrome auto-flips with the active color-scheme: Storybook's light/dark preference, the OS, whatever). Any role you set in chrome replaces that literal with a var(--<prefix>-<your-token>) reference so chrome reflows with your own theme switches instead.
The ten roles (closed set, also exported as CHROME_ROLES and the ChromeRole type from @unpunnyfuns/swatchbook-core):
| Role | Reads as |
|---|---|
surfaceDefault | --swatchbook-surface-default |
surfaceMuted | --swatchbook-surface-muted |
surfaceRaised | --swatchbook-surface-raised |
textDefault | --swatchbook-text-default |
textMuted | --swatchbook-text-muted |
borderDefault | --swatchbook-border-default |
accentBg | --swatchbook-accent-bg |
accentFg | --swatchbook-accent-fg |
bodyFontFamily | --swatchbook-body-font-family |
bodyFontSize | --swatchbook-body-font-size |
Each entry emits a :root alias that redirects the chrome read to your target token's already-emitted CSS variable:
defineSwatchbookConfig({
resolver: 'tokens/resolver.json',
cssVarPrefix: 'ds',
chrome: {
surfaceDefault: 'color.brand.bg.primary',
textDefault: 'color.brand.fg.primary',
accentBg: 'color.brand.accent.primary',
// …
},
});
Produces a single :root chrome block with all ten roles declared: overrides become var(...) references that inherit per-theme values automatically, the rest stay on DEFAULT_CHROME_MAP's hard-coded literals:
:root {
color-scheme: light dark;
--swatchbook-surface-default: var(--ds-color-brand-bg-primary);
--swatchbook-surface-muted: light-dark(#f4f4f5, #1e293b);
--swatchbook-surface-raised: light-dark(#ffffff, #111827);
--swatchbook-text-default: var(--ds-color-brand-fg-primary);
--swatchbook-text-muted: light-dark(#4b5563, #94a3b8);
--swatchbook-border-default: light-dark(#e5e7eb, #334155);
--swatchbook-accent-bg: var(--ds-color-brand-accent-primary);
--swatchbook-accent-fg: light-dark(#ffffff, #0b1220);
--swatchbook-body-font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--swatchbook-body-font-size: 14px;
}
Composite sub-field targets are accepted. typography.body is a composite token that emits one sub-variable per field; the chrome map can target those directly (bodyFontSize: 'typography.body.font-size') even though the sub-field isn't a standalone token ID.
Unknown role keys (outside the ten above) and target paths that don't resolve to any token or composite sub-field in any theme produce warn diagnostics (group swatchbook/chrome) and are dropped; the corresponding chrome slot falls back to its hard-coded literal default. The validated entries land on Project.chrome for downstream tooling.
cssOptions
Type: Omit<CSSPluginOptions, 'variableName' | 'permutations' | 'filename' | 'skipBuild'>, optional.
Options forwarded to the @terrazzo/plugin-css instance swatchbook runs internally: for the stylesheet it emits for the Storybook preview and for the Token Listing's names.css derivation. Use this when your production Terrazzo build passes non-default options to plugin-css (legacyHex, a custom transform, include / exclude globs, etc.) and you want the docs-side output to match.
Four fields are managed internally and stripped from the allowed type:
variableName: swatchbook routes naming through@terrazzo/token-tools/css.makeCSSVarwith yourcssVarPrefix; overriding this would desync the listing'snames.cssfrom the emitted stylesheet.permutations: synthesized one-per-theme so CSS emission is axis-aware; overriding breaks the preview's toolbar cascade.filename: the internal stylesheet is captured in memory, never written to disk.skipBuild: settingtruewould null out the listing'spreviewValuederivation, breaking every block that displays a value.
Everything else passes through. Three plugin-css selectors (baseSelector, baseScheme, modeSelectors) pass the type check but swatchbook ignores them; permutations-based emission supersedes them. Setting any produces a swatchbook/css-options warn diagnostic.
defineSwatchbookConfig({
resolver: 'tokens/resolver.json',
cssVarPrefix: 'ds',
cssOptions: { legacyHex: true, include: ['color.*', 'space.*'] },
});
See Aligning with your token build for the end-to-end shared-options pattern.
listingOptions
Type: Omit<TokenListingPluginOptions, 'filename'>, optional.
Options forwarded to @terrazzo/plugin-token-listing. The listing is how swatchbook surfaces authoritative per-token metadata (plugin-css-computed CSS variable names, preview values, alias chains, source file + line references) into Project.listing and the block-side ProjectSnapshot.listing. Use listingOptions.platforms to register additional platforms beyond the built-in css entry:
import swiftPlugin from '@terrazzo/plugin-swift';
defineSwatchbookConfig({
resolver: 'tokens/resolver.json',
cssVarPrefix: 'ds',
listingOptions: {
platforms: {
css: { name: '@terrazzo/plugin-css' },
swift: { name: '@terrazzo/plugin-swift', description: 'UIColor' },
},
},
terrazzoPlugins: [swiftPlugin({ /* swift plugin options */ })],
});
Each platforms entry names a Terrazzo plugin whose identifier-naming logic derives the token's identifier on that platform. The plugin has to be loaded in the build for the reference to resolve; see terrazzoPlugins below.
filename is managed internally (the listing is captured in memory, not written to disk) and is stripped from the option type.
terrazzoPlugins
Type: readonly Plugin[], optional.
Additional Terrazzo plugins loaded alongside swatchbook's internal plugin-css + plugin-token-listing. Required when listingOptions.platforms references anything beyond the built-in css: the referenced plugin has to actually run in the same build for the listing to resolve its naming. Plugins whose output swatchbook doesn't consume are harmless; they run, their output files land in the in-memory output set, nothing else happens.
Common use: load @terrazzo/plugin-swift / -android / -js / -sass alongside so their naming logic populates listing[path].names.<platform> for block consumption.
See Sharing Terrazzo options for the pattern of sharing one options file between terrazzo.config.ts and swatchbook.config.ts.
maxJointArity
Type: number, optional. Default 4.
Maximum arity of joint-divergence detection per token. A joint divergence is a multi-axis tuple where the cartesian-correct value differs from cascade composition of all lower-arity blocks; each emits a compound CSS selector ([data-axis-a="…"][data-axis-b="…"]…) at emission time.
The default of 4 covers the largest joint shapes real-world design systems tend to express: mode × brand × density × contrast or similar. Most consumers never need to change this.
When to bump (5+): the design system has tokens with genuine 5+-axis joint divergences: concretely, tokens where the value at some 5-axis combination differs from what CSS cascade composes from all the 2-axis, 3-axis, and 4-axis blocks. Without the bump, those tuples will resolve to the cascade-composed value rather than the cartesian-correct one. The visual symptom is a wrong color (or other token value) at that specific 5+-axis combination.
When to lower (≤3): load-time work is a concern. Per-token probe work scales as Σ_{k=2..arity} C(|affectedBy|, k) × Π non-default contexts in combo. Tokens affected by many axes including dense (large-context-count) ones dominate. Setting maxJointArity: 1 disables joint-block emission entirely, useful when joint divergences aren't relevant for your pipeline, or as a debugging step.
import { defineSwatchbookConfig } from '@unpunnyfuns/swatchbook-core';
export default defineSwatchbookConfig({
resolver: 'tokens/resolver.json',
// Design system with tokens that genuinely diverge across 5 axes
// simultaneously (e.g., container × state × responsive × mode × status):
maxJointArity: 5,
});
See also
- Axes reference: the runtime model for selection.
- Reference: core: the loader APIs this config drives.
- Sharing Terrazzo options: keep the swatchbook build aligned with a production Terrazzo CLI.