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.
Live on this page
Section titled “Live on this page”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).
Install
Section titled “Install”npm install @unpunnyfuns/swatchbook-switcherPeer 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
Section titled “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.
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;}| Prop | Required | Purpose |
|---|---|---|
axes | yes | Axes the project ships. The component renders one row per entry. Pass an empty array for a presets-only menu. |
presets | no | Saved tuple snapshots rendered above the axes. Empty / omitted = no presets section. |
activeTuple | yes | Active axis selections, keyed by axis name. Drives which pill in each axis row is --active. |
defaults | yes | Fallback values used to fill in axes a preset doesn’t explicitly set. |
lastApplied | yes | Name of the most recently applied preset, or null. Drives the “modified since applied” dot. |
onAxisChange | yes | Receives (axisName, next) when the user clicks an axis pill. Caller decides what “applied” means: set data-* attributes, route, write to global state. |
onPresetApply | yes | Receives the full preset object when a preset pill is clicked. Caller fills in omitted axes from defaults and applies the resulting tuple. |
onKeyDown | no | Optional key handler, typically used by hosts to close their popover on Escape. |
footer | no | Host-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. |
Input shapes
Section titled “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)
Section titled “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
Section titled “Axis-state propagation”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>: setdata-<prefix>-<axis>ondocument.documentElementper change. Pairs withcssVarPrefixfromloadProjectso emitted CSS targets the same selectors. The Storybook addon and the docs-site switcher both use this, though the docs-site switcher drivesmodethrough Starlight’sdata-themeinstead. - Class on
<body>: toggletheme-<value>ondocument.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.
Styling hooks
Section titled “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.