Skip to content

Switcher

ThemeSwitcher is a 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, and this docs site mounts the same component below; reach for the package directly when you want the switcher on a non-Storybook surface.

This same component is the site header’s theme control, in place of a plain light/dark toggle: the mode axis is bridged to Starlight’s own theme, so flipping mode here or in the header updates the page immediately and both instances stay in sync.

Most consumers pick it up transitively via @unpunnyfuns/swatchbook-addon; import { 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).

Terminal window
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.

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, see the live demo above; see apps/docs/src/components/SwatchbookSwitcherButton.tsx for the full popover-trigger setup, including aria-haspopup / aria-expanded wiring on the trigger button.

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. Pass an empty array for 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. Neither switcher mount (the Storybook addon toolbar, this docs site’s navbar) passes one; the package exports a <ColorFormatSelector> a host can slot in here if it wants a color-format control.
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.

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.

The component is pure: it reads its inputs, draws the menu, and calls back when the user clicks. It never touches localStorage / sessionStorage, the DOM outside its own subtree, or the router. Applying a change, and persisting it across reloads, is the host’s policy. Two common shapes for applying it:

  • 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, though the docs-site switcher drives mode through Starlight’s data-theme instead.
  • Class on <body>: toggle theme-<value> on document.body.classList. Useful if the project’s stylesheet uses class-based scoping instead of attribute selectors.

For persistence, the Storybook addon piggybacks on Storybook’s globals API; the docs-site switcher syncs with the site’s own light/dark theme (Starlight’s data-theme + starlight-theme localStorage key) for mode and persists the rest via a separate localStorage key.

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.