Skip to content

Aligning with your token build

If you run Terrazzo’s CLI in production, swatchbook can read the same value-formatting rules, plugin set, and listing metadata, so the swatches, values, and snippets in Storybook match exactly what your apps ship. The pattern is one shared TypeScript module that both terrazzo.config.ts and swatchbook.config.ts import from.

Both the Terrazzo CLI config and the swatchbook config import one shared options module, so the production build and the Storybook preview format values and name variables the same way.shared optionsterrazzo.config.tsswatchbook.config.tsProduction buildStorybook previewmatching values and names

swatchbook is the preview side of a Terrazzo pipeline: it parses DTCG with Terrazzo’s parser and renders what Terrazzo’s plugins emit. The zero-config install needs no direct Terrazzo dependency.

@terrazzo/parser, @terrazzo/plugin-css, and @terrazzo/plugin-token-listing come along with swatchbook-core transitively. The default install already parses tokens, emits the preview stylesheet, and derives the token listing without any Terrazzo entry in your package.json.

swatchbook-core also declares @terrazzo/parser and @terrazzo/plugin-css as peer dependencies, so your package manager resolves them to the copy your project already has instead of nesting a private duplicate. That keeps swatchbook’s internal build and any Terrazzo plugin you add later on the same parser instance, as long as the version ranges agree; a mismatched pin can still split them. The failure this avoids is a second, duplicate parser: a plugin built against one instance cannot read a graph produced by another, and the symptom is plugin output that silently goes missing from the listing.

Put everything both builds agree on in one file. Both configs import from it.

shared-terrazzo-options.ts
import cssPlugin, { type CSSPluginOptions } from '@terrazzo/plugin-css';
import swiftPlugin from '@terrazzo/plugin-swift';
import androidPlugin from '@terrazzo/plugin-android';
import { makeCSSVar } from '@terrazzo/token-tools/css';
import type { Plugin, TokenNormalized } from '@terrazzo/parser';
import type { TokenListingPluginOptions } from '@terrazzo/plugin-token-listing';
/** Value-formatting policy shared by production CSS and swatchbook's preview. */
export const sharedCssOptions: Omit<CSSPluginOptions, 'variableName' | 'permutations'> = {
legacyHex: true,
include: ['color.*', 'space.*', 'radius.*', 'shadow.*', 'typography.*'],
};
// Variable naming is the one policy `sharedCssOptions` can't carry: swatchbook
// manages `variableName` itself and takes the prefix through `cssVarPrefix`, so
// the CLI has to reproduce that naming via its own `variableName`. Both read the
// prefix from here.
export const cssVarPrefix = 'ds';
export const sharedVariableName = (token: TokenNormalized) =>
makeCSSVar(String(token.id), { prefix: cssVarPrefix });
/** Extra plugins whose naming rules we want reflected in `listing[path].names.*`. */
export const sharedPlugins: readonly Plugin[] = [
swiftPlugin({ filename: 'tokens.swift' }),
androidPlugin({ filename: 'tokens.xml' }),
];
/** Platforms the listing should enumerate per token. */
export const sharedListingOptions: Omit<TokenListingPluginOptions, 'filename'> = {
platforms: {
css: { name: '@terrazzo/plugin-css', description: 'CSS custom properties' },
swift: { name: '@terrazzo/plugin-swift', description: 'UIColor / SwiftUI Color' },
android: { name: '@terrazzo/plugin-android', description: 'colors.xml' },
},
};
export { cssPlugin };
terrazzo.config.ts
import { defineConfig } from '@terrazzo/cli';
import tokenListingPlugin from '@terrazzo/plugin-token-listing';
import {
cssPlugin,
sharedCssOptions,
sharedListingOptions,
sharedPlugins,
sharedVariableName,
} from './shared-terrazzo-options';
export default defineConfig({
tokens: 'tokens/**/*.json',
outDir: 'dist',
plugins: [
cssPlugin({
...sharedCssOptions,
variableName: sharedVariableName,
filename: 'tokens.css',
}),
...sharedPlugins,
tokenListingPlugin({
...sharedListingOptions,
filename: 'tokens.listing.json',
}),
],
});
swatchbook.config.ts
import { defineSwatchbookConfig } from '@unpunnyfuns/swatchbook-core';
import {
cssVarPrefix,
sharedCssOptions,
sharedListingOptions,
sharedPlugins,
} from './shared-terrazzo-options';
export default defineSwatchbookConfig({
resolver: 'tokens/resolver.json',
cssVarPrefix,
cssOptions: sharedCssOptions,
listingOptions: sharedListingOptions,
terrazzoPlugins: sharedPlugins,
});

Any change to sharedCssOptions.legacyHex, or a new plugin added to sharedPlugins, now reaches both the production CLI and swatchbook’s Storybook build with one edit. The variable prefix is wired into each side by hand (variableName for the CLI, cssVarPrefix for swatchbook) from the same shared constant, since swatchbook manages variableName internally and won’t accept it through cssOptions. Three swatchbook config fields receive the shared blocks:

Config fieldForwards toControls
cssOptions@terrazzo/plugin-cssvalue formatting (hex vs color()), transforms, include/exclude globs, utility groups
listingOptions@terrazzo/plugin-token-listingper-platform naming, mode metadata, previewValue / subtype hooks
terrazzoPluginsadditional plugins in the buildwhich plugin naming logic is available to the listing

The terrazzo.config.ts above imports defineConfig from @terrazzo/cli, the package a tz build consumer already has installed. @terrazzo/parser also exports a lower-level defineConfig that takes a cwd option; prefer the cli export in user-facing config files.

In a monorepo the shared file usually lives where both the web app’s production build and the design-system package’s Storybook see it.

packages/
tokens/
src/**/*.tokens.json
resolver.json
shared-terrazzo-options.ts ← here
terrazzo.config.ts ← imports ./shared-terrazzo-options
design-system/
swatchbook.config.ts ← imports ../tokens/shared-terrazzo-options
.storybook/
apps/
web/
… consumes the built tokens from packages/tokens/dist/

If the shared module imports from other workspace packages, make sure your bundler (tsdown / rolldown / whatever pnpm -r build runs) resolves those imports in source form, or publish the shared module alongside the tokens package itself.

Why plugin objects, not a “read my Terrazzo config” field

Section titled “Why plugin objects, not a “read my Terrazzo config” field”

swatchbook accepts plugin objects via terrazzoPlugins rather than ingesting a constructed terrazzo.config.ts. Plugin options live inside each plugin’s closure once the factory runs; there’s no general way for swatchbook to read legacyHex (or any other constructor argument) back out of a cssPlugin({ legacyHex: true }) call. A config.terrazzo: TerrazzoConfig field could only inherit tokens + plugins and would silently ignore everything in cssOptions, which is the field most consumers expect alignment for. The shared-module pattern handles every option symmetrically, the same way tsconfig.json extends or vite.config / vitest.config factoring does.

Without alignment, the docs and production diverge in subtle ways:

  • A <ColorTable> cell shows color(display-p3 0.24 0.56 0.89) because swatchbook’s default plugin-css produces the modern color() form. Your production CSS passes legacyHex: true and ships #3d8fe4. Designers QA the docs, sign off on the modern form, and the rollout looks different than expected.
  • <TokenUsageSnippet> emits var(--ds-colour-surface-default) using a British-spelling variableName policy you configured in production, but swatchbook’s default policy emits --ds-color-surface-default. Consumer-facing code reads fine; docs snippets are wrong.
  • A future per-platform column in a block you write renders colorSurfaceDefault for Swift, but production Swift ships ColorSurfaceDefault because @terrazzo/plugin-swift wasn’t loaded in the swatchbook build.

The shared module above closes all three. One structural mismatch it can’t reach: if production reads a flat tokens.json while swatchbook reads a DTCG resolver, the two pipelines see different theme sets and the listing reflects only swatchbook’s resolver view. Keep both on the same source shape; if production needs flat output, emit it with Terrazzo’s plugin-js (or similar) driven from the resolver’s themes.

What swatchbook owns (and you can’t override)

Section titled “What swatchbook owns (and you can’t override)”

Five fields are stripped at the type level. They’re load-bearing for how swatchbook emits CSS and publishes the listing; letting consumers override them breaks the preview model. swatchbook runs its whole Terrazzo build in memory and never writes to disk, which is what two of these fields would otherwise change.

  • cssOptions.variableName: swatchbook routes naming through @terrazzo/token-toolsmakeCSSVar with your cssVarPrefix, so every variable carries the prefix and kebab-cases consistently with what the listing records under names.css. A custom policy would make the listing’s names.css disagree with the emitted stylesheet, which is worse than the default. cssVarPrefix is your only lever for the prefix, and it’s a top-level config field rather than part of the shared-options module; make sure it matches whatever --<prefix>-* your production stylesheet emits.
  • cssOptions.permutations (the internal Terrazzo field that controls per-theme CSS blocks): swatchbook synthesizes one entry per resolver theme (or per layered tuple) so the emitted stylesheet declares each theme under a compound data-<prefix>-<axis> selector. Overriding this defeats axis-aware rendering.
  • cssOptions.filename: the internal stylesheet stays in memory, so there’s no output file to name.
  • cssOptions.skipBuild: setting true nulls out the listing’s previewValue derivation, breaking every block that displays a value.
  • listingOptions.filename: the listing stays in memory too; the outputFile handler finds the entry by filename, and swatchbook owns that handshake.

Everything else passes through untouched.

Soft-inert knobs (type-accepted, runtime-ignored)

Section titled “Soft-inert knobs (type-accepted, runtime-ignored)”

Three plugin-css options pass the type check but swatchbook ignores them, because permutations-based emission supersedes them:

  • baseSelector
  • baseScheme
  • modeSelectors

Setting any of these doesn’t hit a type error because they’re still part of CSSPluginOptions, but swatchbook always drives emission through the permutations API, so they do nothing in the preview. Load emits a swatchbook/css-options warn diagnostic listing the offending keys (visible in the preview’s diagnostic overlay and on Project.diagnostics for programmatic consumers), so the “my setting isn’t taking effect” failure mode turns into an explicit signal.

swatchbook doesn’t transform tokens for production; through the listing it displays the identifiers those transformers produce, so the docs line up with what ships. The transformation happens in the user-configured plugin; swatchbook reads its output.

The Token Listing records names.<platform> for every platform registered in listingOptions.platforms, using the named plugin’s own identifier-naming logic, so blocks read those names without duplicating any rules.

Loading the plugin and naming its platform are both required: a plugin in terrazzoPlugins with no matching listingOptions.platforms entry runs but contributes no names; a platforms entry whose plugin isn’t loaded fails at listing time. With both in place, listing[path].names.swift (or .android, .js, .sass, …) populates automatically, and <TokenDetail> / <ConsumerOutput> render one row per platform in the Consumer Output section with no block-writing required. The names match production only when the plugin invocations match: pass plugin-swift({ /* your prod options */ }), not plugin-swift(), or the rows show a plausible default rather than your production identifier. Keeping that invocation in the shared module is what holds both sides identical.

For custom blocks, the data is on the project snapshot:

const swiftName = project.listing?.[path]?.names?.swift;
// "SwiftKit.Color.surfaceDefault"

See useSwatchbookData for the ProjectSnapshot.listing shape in the block context.

These plugins run purely for their naming contribution to the listing: their output files (tokens.swift, tokens.xml, …) are captured with the rest of the in-memory build and discarded. The exception is a plugin that bypasses the Terrazzo build API and writes through fs directly: during HMR it will try to write files relative to the Storybook preview’s cwd, so prefer plugins that emit through the supported outputFile hook.

Style Dictionary is the other common token build; since 4.x it reads DTCG-shaped JSON natively.

Point both tools at the same source tree and token identity is preserved across pipelines, even though the CSS variable names won’t match character-for-character. swatchbook names variables through Terrazzo’s @terrazzo/token-tools/css.makeCSSVar; Style Dictionary names them through its own transforms (name/cti/kebab, name/cti/camel, custom transforms), and the two don’t round-trip.

swatchbook.config.ts
defineSwatchbookConfig({
resolver: 'tokens/resolver.json',
cssVarPrefix: 'ds',
});
sd.config.js
export default {
source: ['tokens/**/*.tokens.json'],
platforms: {
css: { transformGroup: 'css', files: [{ destination: 'dist/tokens.css', format: 'css/variables' }] },
ios: { transformGroup: 'ios-swift', files: [{ destination: 'dist/Tokens.swift', format: 'ios-swift/class.swift' }] },
},
};

For a preview-only tool the naming gap is usually fine: the swatchbook block shows what the tokens are, your apps ship what Style Dictionary emits, and as long as both read the same DTCG input token identity holds across pipelines. If you do need names to match, either treat DTCG dot-paths as the canonical identifier and display those via <TokenDetail path="…"> (the path is stable across pipelines), or write a thin adapter that exposes Style Dictionary’s computed names through a custom block via useSwatchbookData() (block-writing work the addon doesn’t ship).

The two builds also coexist. Some teams run Terrazzo for CSS + JS targets (because @terrazzo/plugin-css integrates with swatchbook natively) and Style Dictionary for targets Terrazzo doesn’t cover (some legacy XML formats, Flutter, specific design-tool round-trips). Both read the DTCG source tree, both emit to their own dist/; swatchbook aligns with the Terrazzo side and leaves Style Dictionary’s out of view. If you’re weighing whether to add Terrazzo to an SD-only pipeline just for swatchbook’s per-platform names.* columns, adding the matching Terrazzo plugin (plugin-swift, plugin-android) via terrazzoPlugins runs it in-memory for the naming contribution only.

If you don’t run a production token build alongside swatchbook (the tokens exist only for the docs site and Storybook), leave cssOptions, listingOptions, and terrazzoPlugins unset. swatchbook loads plugin-css with defaults and plugin-token-listing with a single css platform. Drift isn’t a concern when there’s only one pipeline.