Skip to main content
Version: Next

Consuming the active theme

Swatchbook's toolbar flips the active tuple. Stories that render under it need a way to pick up the change. Three paths, in order of ergonomic preference:

  1. CSS variables: zero-code, recommended for any component reading tokens through var(--…).
  2. The React hook useActiveAxes(): inside the Storybook preview, the addon already publishes a React context the blocks and decorators use; story code can read from it directly.
  3. DOM observation: the framework-agnostic fallback for Vue / Angular / Svelte / plain HTML, or for React trees outside the preview's provider.

Swatchbook emits CSS custom properties keyed on the active tuple. A component reading those variables updates automatically when the toolbar flips an axis:

.my-button {
background: var(--<prefix>-color-accent-bg);
color: var(--<prefix>-color-accent-fg);
border: 1px solid var(--<prefix>-color-border-default);
}

No JavaScript, no subscriptions, no observers. The cascade does the work. If your stories already use CSS variables for theming, you're done; the rest of this page is for component libraries that read their theme as a JS object at render time.

For CSS-in-JS libraries whose theme values can be var(--…) strings (styled-components, emotion with a var(...)-based theme), see the CSS-in-JS integration; it publishes a ready-made theme accessor without either of the paths below.

React hook inside the preview

If the consumer is a React story / block / decorator running inside the Storybook preview, use the hooks the addon already ships:

import { useActiveAxes, useActiveTheme, useColorFormat } from '@unpunnyfuns/swatchbook-blocks';

function ThemeBridge({ children }: { children: ReactNode }) {
const axes = useActiveAxes(); // { mode: 'Dark', brand: 'Brand A', contrast: 'Normal' }
const theme = mapAxesToMyTheme(axes);
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
}

useActiveAxes() reads from the same React context the blocks package publishes. It's populated by swatchbook's preview decorator, so anything inside a story tree sees it. It's reactive: components re-render when the toolbar flips an axis; no observer, no manual subscription.

useActiveTheme() gives the composed tuple string ("Dark · Brand A · Normal"); useColorFormat() gives the active color-format selection. Same shape, same context.

DOM observation (for everything else)

The hooks above require React and the preview's provider tree. When your consumer is neither (a Vue / Angular / Svelte app, plain HTML, or a React tree outside swatchbook's provider), subscribe to the active tuple via MutationObserver on the DOM contract instead:

function readAxes(): Record<string, string> {
const el = document.documentElement;
const out: Record<string, string> = {};
for (const name of el.getAttributeNames()) {
if (name.startsWith('data-<prefix>-')) {
out[name.slice('data-<prefix>-'.length)] = el.getAttribute(name) ?? '';
}
}
return out;
}

const observer = new MutationObserver(() => apply(readAxes()));
observer.observe(document.documentElement, { attributes: true });
apply(readAxes());

Wrap that in whatever idiom your framework uses. Vue (onMounted + ref), Angular (a service with RxJS or a signal), Svelte (onMount + a writable store), plain HTML (attach to application state directly). The DOM contract is identical across frameworks.

For React outside the preview's provider tree, the same pattern applies:

function useSwatchbookAxes(): Record<string, string> {
const [axes, setAxes] = useState(readAxes);
useEffect(() => {
const observer = new MutationObserver(() => setAxes(readAxes()));
observer.observe(document.documentElement, { attributes: true });
return () => observer.disconnect();
}, []);
return axes;
}

Inside the preview, prefer useActiveAxes(); it's reactive without the observer cost and pools updates with the blocks' own re-renders.

What the DOM contract guarantees

  1. data-<prefix>-<axisName>="<context>" attributes on <html>: one per active axis. <prefix> follows your cssVarPrefix config (default swatch).
  2. The same attributes on each story's wrapper element. Redundant with <html> in most cases, but useful if your story iframe hosts multiple wrappers.
  3. Per-tuple CSS emitted under compound attribute selectors, e.g. [data-<prefix>-mode="Dark"][data-<prefix>-brand="Brand A"] { … }. One block per tuple, with the default tuple's values in :root as the fallback.
  4. Attributes update on toolbar flip. Synchronously, before the next paint; observers fire on the microtask queue.

Any change to this shape is a breaking change. The DOM is the API.

What swatchbook doesn't ship

  • No Vue, Angular, or Svelte bindings. React is the only framework with shipped hooks (useActiveAxes / useActiveTheme / useColorFormat), because swatchbook's blocks are React and the addon's preview decorator publishes a React context. Other frameworks consume the DOM contract directly: ten lines of observer code, pick the idiom (ref, signal, store) that fits.
  • No recipes per component library. MUI, Chakra, Mantine, styled-components, emotion, Tailwind class-mode: each has its own theming model. The CSS-variable and DOM-attribute contracts above are the starting points; your component library's theming docs are the rest.

Known gotchas

Component library theme is a JS object: can it read the --<prefix>-* CSS variables directly? Only if the library consumes CSS variables natively. Tailwind v4 does (see the Tailwind integration); any provider whose theme values are var(--…) strings does (see the CSS-in-JS integration). If the theme is a JS object read at render time (MUI's createTheme, Chakra, Mantine), use useActiveAxes() in React, or the DOM-observation path elsewhere, to rebuild the theme object on axis flips.

Tailwind's dark: variant isn't responding to swatchbook. Tailwind looks for .dark on an ancestor, or data-theme="dark" in its attribute mode. Swatchbook writes data-<prefix>-mode="Dark", so the attribute shape and name don't match. Either configure Tailwind to key on swatchbook's attribute (@custom-variant dark (&:where([data-<prefix>-mode=Dark], [data-<prefix>-mode=Dark] *));), or have the preview decorator mirror a .dark class via the DOM-observation path.

Storybook's own manager theme (light/dark chrome around the preview) doesn't flip. That's @storybook/theming, which controls Storybook's UI chrome, a separate concern. See Storybook's manager theming docs.

See also

  • Axes reference: the runtime model for tuples and attribute composition.
  • Integrations: zero-code paths for Tailwind and CSS-in-JS.