Skip to content

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.

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 leaf token, not a lookup by path.
  • token: a fully-realised token: concrete $value, aliases already resolved, nothing left to look up. $type narrows the shape $value takes.
  • cssVar: when the caller has one (a query block reading listing[path].names.css, or falling back to cssVarRef), the presenter renders the visual from it, so the chip reflects the live custom property in-project. Absent, the presenter derives the visual from token.$value directly. Shadow and border build their own $value → CSS mapper 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.

type PresenterRegistry = Partial<Record<TokenType, PresenterComponent>>;

DEFAULT_PRESENTERS keys one component per $type:

$typepresenter
colorColorSwatch
gradientGradientSwatch
numberOpacitySwatch
shadowShadowSample
borderBorderSample
dimensionDimensionSample
strokeStyleStrokeSample
transitionMotionSample
typographyTypeSpecimen
fontFamilyFontFamilySpecimen
fontWeightFontWeightSpecimen

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.

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.

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.

usePresenter resolves a $type against the first registry that supplies it, in this order:

  1. SwatchbookProvider’s presenters prop.
  2. A PresenterContext.Provider wrap.
  3. The ambient registry set by registerPresenters, host-registered, e.g. via swatchbookAddon({ presenters }).
  4. 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?: 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 by DimensionScale’s own visual prop.
  • MotionSample: speed?: MotionSpeed (default 1) and runKey?: number (forces a restart when it changes), set by MotionPreview’s speed pills and replay button.
  • TypeSpecimen, FontFamilySpecimen, FontWeightSpecimen: sample?: string, set by TypographyScale / FontFamilyPreview / FontWeightScale’s own sample prop.
  • 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.

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.