Creating custom presenters
The presenter registry maps each DTCG $type to the component that draws it. Register your own component for a type and every built-in block that displays that type renders it instead; the blocks themselves are untouched.
The contract
Section titled “The contract”A presenter is a plain function component receiving PresenterProps<T>. It renders the fully-realised token it is handed; it never looks anything up.
The built-ins follow a two-source rule for the visual:
cssVarpresent: paint from it. In-project the caller passes the token’s live custom property, so the visual tracks the active axis tuple.cssVarabsent: derive a CSS value fromtoken.$value. This is the standalone path: tests, hand-fed embeds, snapshots without a CSS build.
Honor both and your presenter works everywhere the built-ins do.
Two more props matter. colorFormat is the active color format; respect it for any value text you render (formatColor from the same package matches the built-ins). options carries block-supplied display hints; ignore keys you don’t understand. The Presenter registry reference has the full contract and the table of built-ins.
Build one
Section titled “Build one”A color presenter that paints the token over a light and a dark surface side by side, with the formatted value as text:
import type { ReactElement } from 'react';import type { PresenterProps } from '@unpunnyfuns/swatchbook-blocks';import { formatColor } from '@unpunnyfuns/swatchbook-blocks';
const chip = { width: 48, height: 32, borderRadius: 4 };
export function DualSurfaceSwatch({ path, token, cssVar, colorFormat,}: PresenterProps<'color'>): ReactElement { const paint = cssVar ?? formatColor(token.$value, 'hex').value; const { value } = formatColor(token.$value, colorFormat); return ( <div style={{ display: 'inline-flex', flexDirection: 'column', gap: 4 }}> <div style={{ display: 'flex' }}> <div style={{ padding: 8, background: '#ffffff' }}> <div style={{ ...chip, background: paint }} /> </div> <div style={{ padding: 8, background: '#16161d' }}> <div style={{ ...chip, background: paint }} /> </div> </div> <span style={{ fontFamily: 'monospace', fontSize: 12 }}> {path.split('.').at(-1)}: {value} </span> </div> );}paint is the two-source rule: the live custom property when the caller supplies one, otherwise a value computed from token.$value. The fallback pins hex rather than passing colorFormat through, because the paint must be a valid CSS color and the raw format produces a JSON debug string; the built-in ColorSwatch guards the same way. The value text uses the active colorFormat unguarded, raw included.
Register it standalone
Section titled “Register it standalone”Outside Storybook, the provider takes the overrides directly:
import type { ReactElement } from 'react';import { ColorPalette, SwatchbookProvider } from '@unpunnyfuns/swatchbook-blocks';import wire from './tokens-snapshot.json';import { DualSurfaceSwatch } from './DualSurfaceSwatch';
export function TokenDocs(): ReactElement { return ( <SwatchbookProvider snapshot={wire} presenters={{ color: DualSurfaceSwatch }}> <ColorPalette filter="color.**" /> </SwatchbookProvider> );}The merge is partial and per-$type: color now renders DualSurfaceSwatch, every other type keeps its built-in.
Every per-type block reads its visual through usePresenter, so the override reaches ColorPalette and the rest of the per-type blocks with no opt-in. ColorTable, TokenNavigator’s inline leaf preview, and TokenDetail’s composite preview draw their own visuals and do not consult the registry.
Register it inside Storybook
Section titled “Register it inside Storybook”Pass the overrides to swatchbookAddon({ presenters }) in the preview config. One registration covers both story renders and provider-less MDX doc blocks: the addon forwards the presenters into the ambient registry that every built-in block consults.
import { definePreview } from '@storybook/react-vite';import swatchbookAddon from '@unpunnyfuns/swatchbook-addon';import { DualSurfaceSwatch } from './DualSurfaceSwatch';
export default definePreview({ addons: [swatchbookAddon({ presenters: { color: DualSurfaceSwatch } })] });The merge is partial and per-$type, matching the presenters prop: color renders DualSurfaceSwatch, every other type keeps its built-in.
Override within one subtree
Section titled “Override within one subtree”To swap presenters for one region of a story or MDX body rather than the whole preview, wrap that region in the raw context:
import { ColorPalette, mergePresenters, PresenterContext } from '@unpunnyfuns/swatchbook-blocks';import { DualSurfaceSwatch } from './DualSurfaceSwatch';
<PresenterContext.Provider value={mergePresenters({ color: DualSurfaceSwatch })}> <ColorPalette filter="color.**" /></PresenterContext.Provider>;This is the same context SwatchbookProvider populates, and usePresenter reads the nearest provider. The wrapper sits under the addon’s, so the override applies to everything inside it and nothing outside, and wins over an ambient registration from the factory. mergePresenters fills the unlisted types with the built-ins, exactly as the presenters prop does.
Embedding directly
Section titled “Embedding directly”A custom presenter is just a component. You can also render it directly with hand-fed props, no provider and no registry, the same way the built-ins support it; see Embedding directly.
See also
Section titled “See also”- Presenter registry: the full contract, the built-ins table, the options bag.
- Rendering blocks standalone: running blocks outside Storybook, where the
presentersprop lives.