Skip to main content
Version: 0.61

Switcher

Published as @unpunnyfuns/swatchbook-switcher. Framework-agnostic React component that draws the theme-switcher menu — preset pills, one row per axis, an optional footer for host-specific controls. The Storybook addon's toolbar mounts it inside a popover; the docs site's navbar mounts the same component. Reach for this package directly when you want the switcher on a non-Storybook surface.

Most consumers pick it up transitively via @unpunnyfuns/swatchbook-addonimport { ThemeSwitcher } from '@unpunnyfuns/swatchbook-addon' works without adding a sibling dependency. Install directly when you're outside Storybook (Docusaurus, Next.js, a vanilla React app).

Install

npm install @unpunnyfuns/swatchbook-switcher

Peer requirements: React 18+. The package ships its own stylesheet at dist/style.css; import it once at your app's entry, or pull it in via the ./style.css subpath if your bundler honors package.json#exports:

import '@unpunnyfuns/swatchbook-switcher/style.css';

The component is a menu, not a button. It draws no trigger of its own — you decide when it's visible (popover, drawer, inline) and how the active tuple gets applied.

Mount example

Minimal React usage. Pull axes / presets / defaults from @unpunnyfuns/swatchbook-core's loadProject output (or any other source that produces matching shapes); track the active tuple yourself.

src/components/MySwitcher.tsx
import { ThemeSwitcher, type SwitcherPreset } from '@unpunnyfuns/swatchbook-switcher';
import '@unpunnyfuns/swatchbook-switcher/style.css';
import { useState, useCallback } from 'react';

export function MySwitcher({ axes, presets, defaults }) {
const [activeTuple, setActiveTuple] = useState(defaults);
const [lastApplied, setLastApplied] = useState<string | null>(null);

const onAxisChange = useCallback((axis: string, next: string) => {
setActiveTuple((prev) => ({ ...prev, [axis]: next }));
// prefix from loadProject config; must match cssVarPrefix
document.documentElement.setAttribute(`data-sb-${axis}`, next);
setLastApplied(null);
}, []);

const onPresetApply = useCallback((preset: SwitcherPreset) => {
const tuple = { ...defaults, ...preset.axes };
setActiveTuple(tuple);
for (const [axis, value] of Object.entries(tuple)) {
// prefix from loadProject config; must match cssVarPrefix
document.documentElement.setAttribute(`data-sb-${axis}`, value);
}
setLastApplied(preset.name);
}, [defaults]);

return (
<ThemeSwitcher
axes={axes}
presets={presets}
activeTuple={activeTuple}
defaults={defaults}
lastApplied={lastApplied}
onAxisChange={onAxisChange}
onPresetApply={onPresetApply}
/>
);
}

The pattern is identical across hosts — only the surrounding chrome changes (Docusaurus navbar plugin, a Next.js layout component, an emotion-themed React app). The component never reaches into the DOM itself, so wherever the data-<prefix>-<axis> attributes need to land is up to the caller.

For a working example, the docs site you're reading mounts <ThemeSwitcher> inside the navbar — see apps/docs/src/components/SwatchbookSwitcherButton.tsx for the full popover-trigger setup, including aria-haspopup / aria-expanded wiring on the trigger button.

Props

interface ThemeSwitcherProps {
axes: readonly SwitcherAxis[];
presets?: readonly SwitcherPreset[];
activeTuple: Readonly<Record<string, string>>;
defaults: Readonly<Record<string, string>>;
lastApplied: string | null;
onAxisChange(axisName: string, next: string): void;
onPresetApply(preset: SwitcherPreset): void;
onKeyDown?(event: KeyboardEvent<HTMLDivElement>): void;
footer?: ReactElement | null;
}
PropRequiredPurpose
axesyesAxes the project ships. The component renders one row per entry. Omit this to draw a presets-only menu.
presetsnoSaved tuple snapshots rendered above the axes. Empty / omitted = no presets section.
activeTupleyesActive axis selections, keyed by axis name. Drives which pill in each axis row is --active.
defaultsyesFallback values used to fill in axes a preset doesn't explicitly set.
lastAppliedyesName of the most recently applied preset, or null. Drives the "modified since applied" dot.
onAxisChangeyesReceives (axisName, next) when the user clicks an axis pill. Caller decides what "applied" means — set data-* attributes, route, write to global state.
onPresetApplyyesReceives the full preset object when a preset pill is clicked. Caller fills in omitted axes from defaults and applies the resulting tuple.
onKeyDownnoOptional key handler — typically used by hosts to close their popover on Escape.
footernoHost-specific node rendered after the axes (e.g. the Storybook addon's color-format picker). The switcher itself ships no color-format UI.

Input shapes

interface SwitcherAxis {
name: string;
contexts: readonly string[];
default: string;
description?: string;
source?: 'resolver' | 'layered' | 'synthetic';
}

interface SwitcherPreset {
name: string;
axes: Partial<Record<string, string>>;
description?: string;
}

SwitcherAxis and SwitcherPreset are structurally compatible with Project.axes / Project.presets from @unpunnyfuns/swatchbook-core — pass them through unchanged. The shapes are re-declared in the switcher package so it stays framework-agnostic and doesn't pull core as a runtime dependency.

presetTuple(preset, axes, defaults)

function presetTuple(
preset: SwitcherPreset,
axes: readonly SwitcherAxis[],
defaults: Readonly<Record<string, string>>,
): Record<string, string>;

Compose a preset's sanitized partial tuple with the axis defaults — the omitted axes fall back to their defaults, and any context the preset names that isn't listed on its axis is silently dropped. Mirrors the preview decorator's own fallback logic so what a host sends out matches what the preview honors.

The Storybook addon's manager toolbar imports this directly to avoid carrying a byte-identical local copy; other hosts that apply presets through their own pathway are welcome to.

Axis-state propagation

The component is pure: it reads its inputs, draws the menu, and calls back when the user clicks. It does not:

  • Read or write to localStorage / sessionStorage.
  • Touch the DOM outside its own subtree.
  • Subscribe to the routing or framework state.

That makes it portable but also means the host owns the policy for what "applying a preset" or "switching an axis" actually does. The two common shapes:

  • Attribute on <html> — set data-<prefix>-<axis> on document.documentElement per change. Pairs with cssVarPrefix from loadProject so emitted CSS targets the same selectors. The Storybook addon and the docs-site switcher both use this.
  • Class on <body> — toggle theme-<value> on document.body.classList. Useful if the project's stylesheet uses class-based scoping instead of attribute selectors.

Persisting across reloads is also up to the host. The Storybook addon piggybacks on Storybook's globals API; the docs-site switcher syncs with Docusaurus's useColorMode for mode and persists the rest via localStorage.

Styling hooks

The component renders within a fixed .sb-switcher container. Class names follow BEM-style namespacing (.sb-switcher__pill, .sb-switcher__section-label, .sb-switcher__divider, …). Visual polish — surface, border, focus ring — comes from the bundled style.css.

For deeper restyling, target the class names from your own stylesheet after the bundled one has loaded. The chrome variables (--swatchbook-*) the blocks use are not consumed here; the switcher's surface is intentionally self-contained.