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.
Dependencies
Section titled “Dependencies”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.
The shared-options module
Section titled “The shared-options module”Put everything both builds agree on in one file. Both configs import from it.
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 };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', }), ],});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 field | Forwards to | Controls |
|---|---|---|
cssOptions | @terrazzo/plugin-css | value formatting (hex vs color()), transforms, include/exclude globs, utility groups |
listingOptions | @terrazzo/plugin-token-listing | per-platform naming, mode metadata, previewValue / subtype hooks |
terrazzoPlugins | additional plugins in the build | which 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.
Monorepo layouts
Section titled “Monorepo layouts”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.
Drift symptoms
Section titled “Drift symptoms”Without alignment, the docs and production diverge in subtle ways:
- A
<ColorTable>cell showscolor(display-p3 0.24 0.56 0.89)because swatchbook’s defaultplugin-cssproduces the moderncolor()form. Your production CSS passeslegacyHex: trueand ships#3d8fe4. Designers QA the docs, sign off on the modern form, and the rollout looks different than expected. <TokenUsageSnippet>emitsvar(--ds-colour-surface-default)using a British-spellingvariableNamepolicy 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
colorSurfaceDefaultfor Swift, but production Swift shipsColorSurfaceDefaultbecause@terrazzo/plugin-swiftwasn’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-tools’makeCSSVarwith yourcssVarPrefix, so every variable carries the prefix and kebab-cases consistently with what the listing records undernames.css. A custom policy would make the listing’snames.cssdisagree with the emitted stylesheet, which is worse than the default.cssVarPrefixis 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 compounddata-<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: settingtruenulls out the listing’spreviewValuederivation, breaking every block that displays a value.listingOptions.filename: the listing stays in memory too; theoutputFilehandler 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:
baseSelectorbaseSchememodeSelectors
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.
Per-platform identifier display
Section titled “Per-platform identifier display”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
Section titled “Style Dictionary”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.
defineSwatchbookConfig({ resolver: 'tokens/resolver.json', cssVarPrefix: 'ds',});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.
When you don’t need any of this
Section titled “When you don’t need any of this”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.
See also
Section titled “See also”- Config reference:
cssOptions/listingOptions/terrazzoPlugins: the prop-by-prop contract. - The token pipeline: how swatchbook’s virtual module differs from a file-emitting build.
@terrazzo/plugin-token-listingsource: the listing format swatchbook consumes.- Style Dictionary docs: DTCG support landed in 4.0; alignment-friendly configurations start there.