Presenter registry
A presenter renders one fully-realised DTCG token: no project, no graph, no listing. Each per-type block in Overview blocks renders its visual through one, and every built-in is an exported component you can embed directly. The registry is what lets a consumer swap one $type’s visual, a custom color chip say, and have every built-in block that displays a color pick it up, without touching ColorPalette or TokenNavigator internals.
PresenterProps
Section titled “PresenterProps”Every presenter, built-in or third-party, receives the same shape:
import type { ColorFormat } from '@unpunnyfuns/swatchbook-blocks';import type { RealisedToken, TokenType } from '@unpunnyfuns/swatchbook-core/token-value-types';
interface PresenterProps<T extends TokenType = TokenType> { path: string; token: RealisedToken<T>; cssVar?: string | undefined; colorFormat: ColorFormat; options?: Record<string, unknown> | undefined;}path: the token’s dot-path. Label/provenance; the presenter renders the leaftoken, not a lookup by path.token: a fully-realised token: concrete$value, aliases already resolved, nothing left to look up.$typenarrows the shape$valuetakes.cssVar: when the caller has one (a query block readinglisting[path].names.css, or falling back tocssVarRef), the presenter renders the visual from it, so the chip reflects the live custom property in-project. Absent, the presenter derives the visual fromtoken.$valuedirectly. Shadow and border build their own$value → CSSmapper for that fallback rather than reusing a display formatter, because a display string for those types isn’t always a valid CSS property value; motion always renders from the realised value; duration/easing can’t be recovered from a shorthand transition var.colorFormat: the active color format. Color and gradient presenters use it; the rest receive it for a uniform signature and ignore it.options: block-level display hints, described below. Not part of the token; a custom presenter is free to ignore every key.
A presenter is a plain function component: (props: PresenterProps<T>) => ReactElement.
Built-ins
Section titled “Built-ins”type PresenterRegistry = Partial<Record<TokenType, PresenterComponent>>;DEFAULT_PRESENTERS keys one component per $type:
$type | presenter |
|---|---|
color | ColorSwatch |
gradient | GradientSwatch |
number | OpacitySwatch |
shadow | ShadowSample |
border | BorderSample |
dimension | DimensionSample |
strokeStyle | StrokeSample |
transition | MotionSample |
typography | TypeSpecimen |
fontFamily | FontFamilySpecimen |
fontWeight | FontWeightSpecimen |
transition also covers duration and cubicBezier: MotionSample dispatches on the realised $type at runtime, so MotionPreview and TokenNavigator feed all three motion kinds through the one registered key.
The eleven per-type blocks in Overview blocks (ColorPalette, GradientPalette, OpacityScale, ShadowPreview, BorderPreview, DimensionScale, MotionPreview, StrokeStylePreview, TypographyScale, FontFamilyPreview, FontWeightScale) read their visual through usePresenter(type) rather than importing the component directly, so a provider override reaches them with no block-level opt-in. ColorTable, TokenNavigator’s inline leaf preview, and TokenDetail’s CompositePreview render their own visuals directly and don’t consult the registry.
Embedding directly
Section titled “Embedding directly”Every presenter in the table above is exported from @unpunnyfuns/swatchbook-blocks and renders on its own: pass the PresenterProps shape yourself. A presenter renders the realised token it is handed, it does not look one up by path, so you supply the token (and optionally a cssVar) from your own resolution. Useful for custom MDX layouts that don’t want the full overview chrome, and for placing a single visual next to prose.
<MotionSample path={path} token={token} colorFormat={colorFormat} options={{ speed: 1 }} />The option keys each built-in reads are listed in Options bag.
import type { MotionSpeed } from '@unpunnyfuns/swatchbook-blocks';
type MotionSpeed = 0.25 | 0.5 | 1 | 2;MotionSpeed is the union of MotionSample’s four allowed playback rates, re-exported for hosts that drive the speed externally.
Overriding a presenter
Section titled “Overriding a presenter”presenters?: PresenterRegistry on SwatchbookProvider merges over DEFAULT_PRESENTERS, keyed by $type; a type left out of the override keeps the built-in.
import type { ReactElement } from 'react';import type { PresenterProps } from '@unpunnyfuns/swatchbook-blocks';import { ColorPalette, SwatchbookProvider } from '@unpunnyfuns/swatchbook-blocks';import wire from './tokens-snapshot.json';
function MyColorSwatch({ path, token, cssVar }: PresenterProps<'color'>): ReactElement { return <div title={path} style={{ background: cssVar ?? String(token.$value) }} />;}
export function TokenDocs(): ReactElement { return ( <SwatchbookProvider snapshot={wire} defaultAxes={{ mode: 'Light' }} presenters={{ color: MyColorSwatch }}> <ColorPalette filter="color.**" /> </SwatchbookProvider> );}ColorPalette now renders MyColorSwatch for every color row; every other $type keeps its built-in. The merge is per-$type, not per-block: there’s no mechanism to override a presenter for one block while leaving another block’s use of that same type on the built-in.
The prop is a convenience over two exported pieces. mergePresenters(overrides?: PresenterRegistry): PresenterRegistry returns DEFAULT_PRESENTERS with the overrides merged in per $type, and PresenterContext is the React context (Context<PresenterRegistry>, defaulting to DEFAULT_PRESENTERS) that every usePresenter call reads; SwatchbookProvider provides the merged result through it. Inside Storybook the addon mounts the provider, so the primary route is swatchbookAddon({ presenters }), which registers the overrides for every block including provider-less MDX doc blocks; to scope an override to one subtree instead, wrap it in PresenterContext.Provider with a merged registry. Creating custom presenters walks through both.
Precedence
Section titled “Precedence”usePresenter resolves a $type against the first registry that supplies it, in this order:
SwatchbookProvider’spresentersprop.- A
PresenterContext.Providerwrap. - The ambient registry set by
registerPresenters, host-registered, e.g. viaswatchbookAddon({ presenters }). DEFAULT_PRESENTERS.
registerPresenters(overrides?: PresenterRegistry) lives on @unpunnyfuns/swatchbook-blocks/host and is what swatchbookAddon({ presenters }) calls at preview-init; it seeds the provider-less fallback that MDX doc blocks read, while an explicit provider or context override still wins for its own subtree.
Options bag
Section titled “Options bag”options?: Record<string, unknown> carries block-level display hints, RJSF ui:options-style: a query block passes its own config through; the presenter it renders reads the keys it understands and ignores the rest. Custom presenters can ignore options entirely.
Keys the built-ins read:
DimensionSample:visual?: 'length' | 'radius' | 'size'(default'length'), set byDimensionScale’s ownvisualprop.MotionSample:speed?: MotionSpeed(default1) andrunKey?: number(forces a restart when it changes), set byMotionPreview’s speed pills and replay button.TypeSpecimen,FontFamilySpecimen,FontWeightSpecimen:sample?: string, set byTypographyScale/FontFamilyPreview/FontWeightScale’s ownsampleprop.OpacitySwatch:sampleColorVar?: string, the color painted behind the checkerboard so the chip’s alpha reads visually; defaults to the swatchbook accent surface.
A custom DimensionSample replacement registered via presenters={{ dimension: MyDimension }} still receives options={{ visual: 'radius' }} when rendered inside DimensionScale with visual="radius"; whether it reads that key is up to it.
A custom transition presenter can read options.runKey, a counter that changes on each replay, to support MotionPreview’s replay button the way the built-in MotionSample does.
Realised value vs. listing preview
Section titled “Realised value vs. listing preview”A presenter shows the realised token it’s handed: token.$value, run through its own $value → CSS mapping (or the cssVar, when supplied). That’s a different source from the query blocks’ table/tree columns, which read listing[path].previewValue, the string @terrazzo/plugin-token-listing derives from the consumer’s plugin-css output (see useSwatchbookData). The two normally agree; a presenter can diverge from the listing’s preview string for a type where the plugin’s display format and the presenter’s CSS mapping choose different representations of the same value. The visual chip itself still reflects the live CSS var in-project whenever cssVar is passed in, independent of which string is shown as text.