Skip to main content
A geom renderer owns the React paint for one (geom, coord) composition — the render half of a geom, opposite its compile half. Renderers paint observations and respond to hover; they don’t own layout, guides, or data-label placement. The registry is keyed on the (geom, coord) pair, so bar × cartesian (a column) and bar × polar (a pie wedge) are independent renderers that share no code. Adding one never touches the other.

defineGeomRenderer is dual-target

The same function binds a renderer two ways, depending on what you pass first:

A whole custom geom

Pass a Geom instance to pair both halves. The result carries the compile definition on .definition, so dropping it into plugins registers the geom and derives its typed builder method:

Render-only overrides

Pass a built-in geom name to rebind only the paint half. The built-in compile half keeps running — positions, stacking, scales, and axes are unchanged — so only the look differs. This is how you restyle bars without reinventing bar geometry:
The name is constrained to the built-in geom union, so overriding an unknown name is a compile-time error. To restyle a custom geom, rebind the definition you already hold rather than its name.

The contract

Every renderer implements a GeomRenderContract:
'cartesian' | 'polar'
required
The coordinate system this contract paints under — the discriminant of the coordSystem the render receives. A geom binds one contract per coord.
A flipped chart resolves the 'cartesian' contract: flip is an axis-assignment variant, a CartesianCoordSystem with mainAxis: 'y', not a geometric paradigm of its own. Branch on coordSystem.mainAxis inside the cartesian render rather than writing a third contract. The spec-level 'cartesian' | 'polar' | 'flip' union is a different one — it’s what a definition’s supportedCoordTypes answers with.
(input) => ReactNode
required
Paints the observations. Receives { layer, coordSystem, shouldAnimateTransitions, formattingLocale, intro, panelRect } — see Reading the panel rect and Entrances and transitions. The { fn, options: { overlay: true } } form paints into a screen-aligned portal instead — see Live geoms.
(input) => ReactNode
required
Paints the hover state for this layer’s observations. Receives the hovered primary hit plus its group and related hits, the coordSystem, and the panelRect.
(input) => ReactNode
required
Cross-layer hints — e.g. dots dropped on a line at the hovered x. Return null to opt out.
'crosshair' | 'band' | null
The positional guide drawn when this is the hovered layer: 'band' shades the hovered category (bars), 'crosshair' draws a rule at the hovered value (lines, areas). Omit or pass null to draw none (scatter points).
SwatchShape
The swatch shape shown for this geom in the legend and tooltip.
(input) => ReactNode
Optional repaint of the matched subset for the highlight overlay. Falls back to render when omitted. Override it when the plain grouped render would misrepresent a matched subset. Its input is the render input plus sourceLayer (the full layer the subset was filtered from — context the subset can’t see, like a stack’s silhouette).
(input) => OverlayAnchor | null
Where a matched observation’s highlight marker sits, in normalized [0,1] coord space. Receives { layer, coordSystem, observation } and returns { x, y }, or null for an observation it can’t place. Built-in geoms whose highlight strategy is 'overlay-anchor' are required to supply it; for a custom geom it’s optional.
renderHighlight and getOverlayAnchor serve layers that compose highlights, which the compiler resolves per built-in geom — so they’re live on a render-only override of a bar or a line, and idle on a custom geom’s own layers.
(input) => RenderHitTester
A per-cursor spatial query for a layout geom. Only consulted when the compile half declares spatialKind: 'render-hit-test'.
Registering a renderer whose (geom, coord) pair never appears, or whose compile half has no render half at all, surfaces as a plugin diagnostic rather than a throw — MISSING_GEOM_RENDERER, DUPLICATE_REGISTERED_TYPE, and the three render-hit-test codes all originate here.

Reading the panel rect

render, hitTest, renderHover, and renderHighlight all receive panelRect: the data rectangle in SVG-local pixels, already inset from axes and chrome. Marks paint in local 0…width / 0…heightx/y already positioned the geom-layers SVG, so adding them double-offsets. GraphRenderer’s onResize reports the whole graph container instead — don’t scrape [data-testid="plot"]. Width and height are the aspect a layout geom needs for marks that must stay circular or uniformly spaced:
An overlay-hosted render also gets overlay.panelRect, which is the same panel in client pixels for HTML placement — not a second copy of the layout rect.

Reading scaled positions

The renderer reads coordinates off each Observation with the value readers — it never re-derives a position. The compiler already scaled them; the reader hands you the resolved value, and helpers convert it to the paint frame:
A reader returns null for a missing or unscaled value — guard for it and skip the observation rather than painting NaN. For a custom scalar aesthetic (a candlestick’s open/close), getScaledAesthetic(observation, 'open') reads the scaled position the mapper wrote into a derived column. For a render-only override of a built-in geom, higher-level readers like getBarRectBounds(mainAxis, observation) hand you the geom’s normalized [0,1] bounds directly.

Reading paint

Paint resolves through the stylesheet’s three-tier cascade — override → data → default, first answer wins. The value readers getColor, getAlpha, getSize, getStrokeWidth and getLineType are the data tier alone: they hand you what the encoding decided and nothing else. Reading one and falling back to a constant therefore skips the two tiers around it — a chart’s style.geom({ color }, { where }) override, and the built-in default that gives an unmapped layer the house colour. The mark paints its own hard-coded value where the chart’s stylesheet says otherwise. Build a resolver over the layer to read the whole cascade instead. geomReaders(layer) reads that layer’s compiled stylesheet, so a get answers with overrides, authored defaults and built-in defaults folded in:
colorScheme is yours to supply. createStyleResolver takes it as an option and falls back to 'light', and no exported hook carries the chart’s active scheme into a renderer — so thread in the same value you pass <GraphProvider colorScheme>, through a geom param or your own React context. A resolver left on the default paints light colours on a dark chart.
The Styling page covers the cascade itself — tiers, selectors, tokens and states.

Entrances and transitions

Two members of the render input drive motion, and both are offered rather than imposed — a renderer that ignores them simply appears at once.
LayerIntroPlan | null
The engine’s plan for how this layer enters on first mount, discriminated on type: { type: 'grow', durationSeconds, baseline, growAxis, delayByKey } or { type: 'wipe', durationSeconds, axis }. Every timing is already in seconds with the chart’s duration scale applied — apply the numbers verbatim.
boolean
Whether an ongoing data change should tween rather than cut. Separate from the one-shot entrance.
Which plan a layer gets follows from its compile half’s spatialKind: An empty layer also resolves to null. So a layout or overlay geom never receives a plan and owns its own entrance, while a custom geom declaring 'buckets' does get a wipe and has to consume it or it pops in while every built-in layer animates.

Hover

Hover has two render entry points, both in the contract:
  • renderHover repaints this layer’s hovered observation — usually the same one drawn bolder or haloed. Because positions come from the compiled observation, the highlight lands exactly over the base shape.
  • renderHoverCompanions paints hints on other layers at the hovered x. Return null when the observation is its own highlight.

Hit-testing layout geoms

Most geoms are hit-tested from their scaled positions — the compiler builds the index from spatialKind. But a layout geom computes its geometry with an algorithm render-side (Sankey ribbons, Treemap tiles, Voronoi cells), so the compiler can’t see the shapes to index them. Such a geom declares spatialKind: 'render-hit-test' on its compile half and supplies a hitTest factory on the contract. The factory runs once per data change or panel resize and returns a per-cursor tester; the cursor arrives in panel-local [0,1] (top-left origin), and the tester returns the identity key of the observation under it, or null. It receives the same panelRect as render:
The renderer registers the tester on your behalf, so central hover, tooltips, and guides work the same as for a built-in geom. (useGeomHitTest is the underlying hook, but the contract’s hitTest is the path you write against.) The key the tester returns is the observation’s identityKey value, so the compile half has to name an identity its own geometry owns — { variable: 'markId' } or 'index'. Left on the default 'x-group', the lookup is built empty and every hit resolves to nothing (RENDER_HIT_TEST_IDENTITY).

Live and draggable geoms

A geom that runs its own simulation or drag interaction — a force-directed graph — must own its pointer events. For that, render takes the overlay-hosted form: { fn, options: { overlay: true } }. Its function receives input.overlay with a pushHover to feed the central hover store and overlay.panelRect (client pixels) for HTML placement. Layout panelRect is on the same input. The overlay resolves each pushHover against the same render-hit-test index, so the compile half declares spatialKind: 'render-hit-test' and an identity of its own here too:
Without that spatialKind, the overlay still paints but every pushHover resolves against no index — no hover, no tooltip — and the chart reports OVERLAY_REQUIRES_RENDER_HIT_TEST. An overlay render and a hitTest factory are alternatives, not a pair: the overlay pushes hover from its own handlers, the factory answers the central pull. Declaring both is CONFLICTING_RENDER_HIT_TEST, and only the overlay is used. Declaring neither on a 'render-hit-test' layer is MISSING_RENDER_HIT_TEST — the layer paints with no hover at all. useGeomHover(layerId) is the lower-level push hook the overlay form wraps — reach for it only when the overlay form doesn’t cover your case.
  • Custom geoms — the compile half a renderer pairs with
  • Styling — the cascade a renderer reads paint from
  • Interactivity — the hover model renderers plug into
  • Slots — replace a region’s render without writing a geom
  • Diagnostics — what a misdeclared renderer reports