Axes
An axis is one independent dimension of your theming model — mode, brand, density, contrast, whatever fits your system. Each axis has a set of named contexts (one of which is the default), and at any moment exactly one context is selected per axis.
The axis shape is the alternative to a flat string-ID model ("light", "dark", "brand-a-dark-compact"). One dimension becomes two; two become four. Modelling each dimension independently keeps the combination-counting out of your theme names and into composition at runtime. If your system has exactly one dimension and no plans for more, @storybook/addon-themes' single-string-ID model is simpler and a better fit.
Tuples, not strings
The canonical representation of "what theme is active" is a tuple: one context selected per axis. For a resolver with mode and brand:
{ mode: 'Dark', brand: 'Brand A' }
Every resolved token, every CSS selector, every panel readout keys on this tuple. The composed string ("Dark · Brand A") exists for display.
Data attributes
When a tuple is active, the preview decorator writes one data-<prefix>-<axisName>="<context>" attribute per axis to <html> and the story wrapper. The prefix follows cssVarPrefix (default swatch):
<html data-swatch-mode="Dark" data-swatch-brand="Brand A">
CSS emission matches those attributes with compound selectors. Each non-default tuple gets its own flat block — every var redeclared — with :root carrying the default:
:root {
--swatch-color-surface-default: #ffffff;
}
[data-swatch-mode="Dark"][data-swatch-brand="Default"] {
--swatch-color-surface-default: #0f172a;
}
[data-swatch-mode="Dark"][data-swatch-brand="Brand A"] {
--swatch-color-surface-default: #111827;
}
Flat emission avoids collision-detection — DTCG allows multiple modifiers to write to the same token path, and flat blocks let the last-written tuple win cleanly.
These attributes and CSS vars are swatchbook's preview scaffolding, not a public styling API. Consumer components shouldn't key production theming off data-swatch-* selectors.
Independence
Axes are independent. Selecting mode=Dark does not constrain brand; the resolver evaluates each modifier in resolutionOrder and each contributes its own layer stack. The toolbar popover reflects this by rendering one dropdown per axis, not a single flat dropdown listing every theme.
Where axes come from
- Resolver input (
config.resolver): each DTCG modifier becomes one axis (source: 'resolver'). - Layered config (
config.axes): each entry becomes one axis (source: 'layered'). - Neither input: a single synthetic axis named
theme(source: 'synthetic').
Read the active tuple from story code:
import { useActiveAxes } from '@unpunnyfuns/swatchbook-addon';
function MyComponent() {
const axes = useActiveAxes();
// axes === { mode: 'Dark', brand: 'Brand A' }
}
Per-story overrides
Force a specific tuple on a single story:
export const DarkBrandA = meta.story({
parameters: {
swatchbook: {
axes: { mode: 'Dark', brand: 'Brand A' },
},
},
});
Omit an axis and it falls back to that axis's default. Invalid axis keys or context values are silently ignored (the decorator falls back to defaults).
Disabling an axis
A resolver may declare more modifiers than a given Storybook wants to surface. List the axis name in config.disabledAxes and the project behaves as if that axis didn't exist for this session: no toolbar dropdown, no extra CSS blocks, the axis pinned to its default context in the active tuple. The Design Tokens panel shows a faint pinned indicator so the suppression isn't invisible. See the disabledAxes field for details.
See also
- Config reference — the
resolver/axes/default/disabledAxes/presetsfields. - Consuming the active theme — how stories pick up axis flips.