Migrating from @storybook/addon-themes
Swatchbook's addon replaces @storybook/addon-themes outright — same toolbar role, richer model (multi-axis, DTCG-native, HMR-aware). If you're coming from addon-themes, this guide maps each of its decorators to the swatchbook equivalent.
TL;DR
addon-themes | Swatchbook equivalent |
|---|---|
withThemeByClassName({ themes, defaultTheme }) | Remove — swatchbook's toolbar sets data-<prefix>-* attributes by default; rewrite any class-based CSS to target [data-<prefix>-mode="Dark"] etc. |
withThemeByDataAttribute({ themes, defaultTheme, attributeName }) | Remove — swatchbook does exactly this, multi-axis. Your stylesheet already consumes the attributes; keep it. |
withThemeFromJSXProvider({ Provider, GlobalStyles, themes }) | Keep using your provider, but feed it swatchbook's theme accessor. See CSS-in-JS integration. |
Your app's CSS stays the same. Your stories stay the same. You delete one decorator and either lose it (class / data-attr cases) or rewire it against the swatchbook provider (JSX case).
Step by step
1. Install the swatchbook addon
npm install -D @unpunnyfuns/swatchbook-addon
Register it in .storybook/main.ts alongside (or instead of) @storybook/addon-themes:
export default {
addons: [
- '@storybook/addon-themes',
+ '@unpunnyfuns/swatchbook-addon',
],
};
If you prefer a gradual migration, leave @storybook/addon-themes registered — both toolbars can coexist during the transition, though users will see two switchers.
2. Define your axes
addon-themes expects a flat themes: Record<string, string> map. Swatchbook expects a DTCG resolver or authored axes. The minimal swatchbook.config.ts for a single-axis setup:
// swatchbook.config.ts
import { defineSwatchbookConfig } from '@unpunnyfuns/swatchbook-core';
export default defineSwatchbookConfig({
tokens: ['tokens/base/**/*.json'],
axes: [
{
name: 'mode',
default: 'Light',
contexts: {
Light: [],
Dark: ['tokens/modes/dark.json'],
},
},
],
});
Multi-axis (mode × brand × contrast, for example) adds more axes entries — see the multi-axis walkthrough.
If your tokens are already in DTCG 2025.10 resolver format, point at the resolver file instead:
export default defineSwatchbookConfig({ resolver: 'tokens/resolver.json' });
3. Drop the addon-themes decorator
Were you using withThemeByClassName?
Your stylesheet probably looks like:
.dark { /* dark overrides */ }
.light { /* light overrides */ }
Option A: Rewire to data-* attrs. Swatchbook's toolbar sets data-<prefix>-mode="Dark" on <html> by default. Replace your class selectors:
-.dark { … }
+[data-sb-mode="Dark"] { … }
If you want the class form instead, keep swatchbook's data attributes as the primary mechanism and add a [data-sb-mode="Dark"] { } → class bridge in your CSS:
[data-sb-mode="Dark"] { }
/* Synonym for anywhere you can't update `.dark` to the attribute selector: */
.dark { /* your overrides */ }
Option B: Consume swatchbook's CSS vars directly. The cleaner migration — once your DTCG tokens are authored, swatchbook emits per-tuple CSS that redefines --<prefix>-* vars under [data-<prefix>-*] selectors inside Storybook. Your components reference the vars directly; class names stop mattering for theming. See the token pipeline.
Were you using withThemeByDataAttribute?
Your stylesheet already consumes data-* attributes. Swatchbook sets the same attributes (prefixed, by default — data-sb-mode rather than bare data-theme). Two choices:
Option A: Update your CSS to swatchbook's prefixed attrs.
-[data-theme="dark"] { … }
+[data-sb-mode="Dark"] { … }
Option B: Drop the prefix in swatchbook's config so the attribute matches your existing CSS.
defineSwatchbookConfig({
cssVarPrefix: '', // → `data-mode`, `data-brand`, etc., no prefix segment
// …
});
Beware: with an empty prefix, data-mode can collide with other libraries' conventions. Prefer Option A.
Were you using withThemeFromJSXProvider?
You have a real theme-object shape the provider expects (emotion's { colors: {…} }, styled-components' theme, MUI's createTheme output).
The migration depends on what the provider does with the theme.
-
If the provider just passes the theme through to
useTheme()/props.themeand your styled components read values from it — emotion, styled-components, and hand-rolled providers — use the CSS-in-JS integration. It ships avirtual:swatchbook/thememodule withvar(--sb-*)string leaves; drop-in for any provider, toolbar flip handled by cascade. -
If the provider derives dependent values from the theme at factory time — MUI's
createThemecomputingpalette.primary.light/contrastTextfrom a base — you need per-tuple resolved values, not var refs. Run Terrazzo's CLI against the same DTCG sources with@terrazzo/plugin-jsto produce the resolved theme objects; swatchbook doesn't bundle that path. -
Vuetify theme config, bootstrap SCSS — same caveat as MUI. Same workaround.
4. Drop the global
addon-themes's theme global (string) doesn't survive the migration — swatchbook uses per-axis globals (swatchbookTheme, and optionally per-axis swatchbookAxes). Stories that read theme from globals need to switch:
-const { theme } = context.globals;
+const { swatchbookTheme } = context.globals;
Per-story overrides change from parameters: { themes: { themeOverride: 'dark' } } to parameters: { swatchbook: { themeOverride: 'Dark' } }. Context key follows the axis-aware swatchbook namespace.
5. Keep addon-themes around?
No. Swatchbook's toolbar supersedes it fully. Once your decorators are rewired, remove @storybook/addon-themes from addons[] and uninstall.
Why no bridge package?
It'd be tempting to ship a @unpunnyfuns/swatchbook-integrations/addon-themes subpath that auto-derives the themes / defaultTheme object withThemeByDataAttribute wants. We considered it and decided against: addon-themes' decorators are single-axis by design, and every bridge shape would either collapse multi-axis projects (losing expressiveness) or fight the addon's flat-string mental model. A documented migration path ends up cleaner than a bridge that half-works.
What you lose
themes: Record<string, string>'s flat mental model — swatchbook makes the multi-axis structure explicit, which is more accurate for most systems but requires one config decision (resolver vs layered axes).withThemeFromJSXProvider's direct theme-object passing — our provider equivalent is via the CSS-in-JS integration, and MUI-style resolved-value factories aren't covered yet.
What you gain
- Multi-axis (mode × brand × contrast × density × whatever) without flattening to 18 theme names.
- DTCG 2025.10 as the source of truth —
resolver.jsoninstead of hand-wired theme maps. - HMR on token edits: change a color value, preview re-renders in place without losing args / scroll state.
- Doc blocks (
TokenTable,TokenNavigator,ColorPalette,TokenDetail) that read from the same source. - MCP server for AI-agent introspection over the same token graph.
- Compound
data-*selectors for runtime theme switching that compose naturally with Tailwind v4 and every CSS-var-consuming library.