(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 aGeom 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 contract
Every renderer implements aGeomRenderContract:
'cartesian' | 'polar'
required
The coordinate system this contract paints under, and 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 is what a
definition’s
supportedCoordTypes
answers with.(input) => ReactNode
required
Paints the observations. Receives
{ layer, coordSystem, styleReaders, colorScheme, animateTransitions, formattingLocale, intro, panelRect }; see
Reading the panel rect and
Entrances and transitions. The { fn, options: { live: true } }
form paints above the chart’s pointer handling instead; see Live geoms.(input) => ReactNode
required
Paints the hover state for this layer’s observations. Receives the hovered
primary hit (a HoverHit, exported from @graphysdk/react) plus its
group and related hits, the layer, coordSystem, styleReaders and
panelRect. Read paint with the 'hovered' state.(input) => ReactNode
required
Cross-layer hints, such as dots dropped on a line at the hovered x. Receives
the
primary and related hits with the layer, coordSystem and
styleReaders. Return null to opt out.'crosshair' | 'band' | null
The positional guide drawn when this is the hovered layer:
'band' shades the
hovered band (bars), 'crosshair' draws a rule at the hovered value (lines,
areas). Omit or pass null to draw none (scatter points).'square' | 'line' | 'circle' | 'area' | 'slice' | 'interval'
The swatch shape shown for this geom in the legend and tooltip.
'interval'
is a stem capped at both ends, upright unless the chart is flipped.(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, for 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. A geom whose
highlightStrategy is 'overlay-anchor' has to supply it, or its layer dims
with nothing raised above it.renderHighlight and getOverlayAnchor serve a geom that declares a
highlightStrategy:
'observation-rerender' calls renderHighlight (or render) with the
matched subset, 'overlay-anchor' calls getOverlayAnchor. The default is
null, so a custom geom that declares none composes no highlights, while a
render-only override of a bar or a line inherits the built-in geom’s strategy.(input) => EditOutlineShape[]
Editing only: the shapes the editor outlines when this layer’s observations are hovered or selected in point and edit. Unlike the
render… methods it returns data, not JSX, always in panel pixels: { kind: 'region', pathData } for the area the observations cover (one path where there are many, with rounded corners traced in), { kind: 'stroke', pathData, width } for a line at the width it is drawn, and { kind: 'dots', centers, size } for circles. The editor draws the outline around them in its own look. Receives { layer, sourceLayer, coordSystem, panelRect, styleReaders, shouldAnimateTransitions }; copy shouldAnimateTransitions onto a region or stroke whose paint springs, so the outline morphs with it. Omit it and the editor outlines each observation’s bounding box. A render-only override that draws the built-in geometry in its own look can return the built-in’s outline: getEditOutlineShapes: (input) => getBuiltInEditOutlineShapes('line', 'cartesian', input).(input) => RenderHitTester
A per-cursor spatial query for a layout geom.
Only consulted when the compile half declares
spatialKind: 'render-hit-test'.(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. Shapes paint in local 0…width / 0…height. x/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 shapes that must stay circular or uniformly spaced:
0…width / 0…height. To place a mark under the pointer, convert the pointer with live.readPanelPoint(event) rather than subtracting a client rect, which is off by the graph’s scale.
Reading scaled positions
The renderer reads coordinates off eachObservation 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:
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 hand you the geom’s normalized [0,1] bounds directly. A tile reads getTileRectBounds(observation). A bar reads createBarBoundsReader(layer, mainAxis, styleReaders, panelRect), built once per render, which narrows each bar to its maxWidth as the built-in bar paints it. getBarRectBounds(mainAxis, observation) returns the full band and ignores the cap.
Paint frames
UnitSpaceSvg stretches the [0, 1] square onto the panel (preserveAspectRatio="none") so paint and hit-test share one frame. Paths belong there. A <circle> in that frame becomes an ellipse on a non-square panel.
Circles and glyphs go in UnitBoxSvg: a nested SVG over a unit-space box with no viewBox. Children live in an unscaled local space, where 50% is the box center and a pixel radius stays a pixel.
UnitSpaceSvg with percent positions and a pixel radius, the same way the built-in point geom paints.
Reading paint
Paint resolves through the stylesheet’s three-tier cascade, override → data → default, and the first answer is used. The value readersgetColor, 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({ fill }, { where }) override, and any default a stylesheet gives an unmapped layer. The geom paints its own hard-coded value where the chart’s stylesheet says otherwise.
Read the whole cascade from styleReaders on the render input. Inside a React renderer under the provider, useGeomStyleReader(layer) is the same object.
A geom with no styleTarget reads the shared geom vocabulary, whose fill and stroke have no built-in value. The color aesthetic still feeds both, so a mapped color arrives, but get('fill') answers nothing on an unmapped layer. Declare a styleTarget with a rest fill, token('geom'), so it always answers. A fill is a ResolvedPaint: a color string, or a gradient or pattern object. toPaintColor reduces it to the one color an SVG attribute takes.
dashArray and strokeWidth reads through resolvePathStroke, with the cap the stroke would otherwise paint: its lineCap read to follow the stylesheet, or a cap of its own. It returns strokeWidth, strokeDasharray and strokeLinecap, named as SVG props so they spread onto a <path> as is, with the dashes scaled by the width. A dotted line is a run of zero-length dashes, which a butt cap leaves invisible, so a dotted stroke comes back round-capped under any cap, and at least 1.5px wide.
fillAlpha reads above 0, at that opacity: the chart menu’s Fill under line writes fillAlpha. createLineFillAreaGenerator(layer.params) from @graphysdk/react builds that area’s path in the unit square, down to the bottom of the panel, following the layer’s curve and missing values the way createLinePathGenerator does for the line.
Key elements by data identity rather than index where you can, so a data change moves an element instead of remounting it. createStableKeyGenerator(layer.data, layer.mapping, layer.id) from @graphysdk/react returns a key function.
The Styling page covers the cascade itself: tiers, selectors, tokens and states.
A geom with several parts
A geom that paints more than one thing declares astyleTarget naming each one, such as a lollipop’s stem beside its dot. styleReaders answers for the observations, as above; styleReaders.part(name) answers for a declared part. Pass the observation you paint it for, so an entry’s where can test it:
aesthetics it declares. Otherwise its paint comes from the stylesheet and the geom’s declared defaults alone, and a part the geom didn’t declare reads back undefined. Pass a state third to read one: stem.get('stroke', observation, 'hovered') is the look renderHover paints.
Entrances and transitions
Two members of the render input drive motion. Both are offered rather than imposed; a renderer that ignores them 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 },
{ type: 'wipe', durationSeconds, axis } or { type: 'fade', durationSeconds }.
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.
spatialKind:
A wipe travels along a continuous path, so it is reserved for the shapes that draw one across the layer. A spanned bucket strokes within its own bucket and joins nothing to the next, so it fades instead, as a tile grid does.
An empty layer also resolves to
null. So a layout or live geom never receives a plan and owns its own entrance, while a custom geom declaring any of the shapes above does get a plan and has to consume it, or it pops in while every built-in layer animates.
@graphysdk/react ships the same primitives the built-in geoms animate through, so a plugin never takes a motion dependency of its own:
GeomCircle, GeomLine and GeomRect spring their geometry to each new value when animateTransitions, and snap when it is off. That is the data-change half, independent of the entrance. All three take positions in whichever space the geom paints in, percentages or unit coords, as long as a value and its successor share units.
Each shape takes effects, the shadow, blend mode and filters a stylesheet gives the geom, read with readGeomEffects(styleReaders, observation, state).
The renderer lays a geom’s overpaint, the paint a stylesheet lays over its shapes, over everything each render hook draws. A geom with detail that should stay clear of it, such as a boxplot’s whiskers and median, sets overpaintPlacement: 'manual' and wraps its main shapes in GeomOverpaint itself. The overpaint then covers what the wrapper holds and nothing else, so draw the strokes that sit under the main shapes before it and anything drawn over them after it:
state: 'hovered' in the hover repaint, and no state at rest. A manual geom that draws no GeomOverpaint takes no overpaint. Wrap a nested view box from outside rather than the shapes inside it, since the pattern is laid out in panel pixels.
A shape springs only while it keeps its element, so key each one on something that survives the change. That holds for the layer too: a generated layer id churns across recompiles and remounts every shape under it, so a chart whose shapes should tween pins its geom: geom.dumbbell({ id: 'pay-gap' }). A grow plan is per shape rather than per layer, so a geom declaring 'rects' or 'points' still animates its own entrance from the plan’s baseline, growAxis and delayByKey.
A theme can replace these shapes through Theme.shapes. A theme shape whose drawing depends on pixels, such as hatching lines a set distance apart, reads the panel’s pixel size with useGeomPanelSize(). It then converts the shape’s percentages to pixels with readPositionPixels(position, extent), or with readRectPixels(rect, panel) for a rect. The resulting pixels are relative to the panel, not to the graph container.
useGeomPanelSize() returns null outside a geom layer and inside a UnitSpaceSvg. Inside a UnitBoxSvg, it returns the size of that box. Where it returns a size, a numeric position is already in pixels and the helpers return it unchanged. They return null only for a string that holds no percentage, and the theme shape should then draw the default shape instead.
A geom that draws shared shapes in unit coordinates puts them inside a UnitSpaceSvg, never a hand-written <svg viewBox>. The panel context only knows the space a renderer component declares, so shapes under a bare view box still read as pixels and a theme shape misreads their coordinates.
Hover
Hover has two render entry points, both in the contract:renderHoverrepaints this layer’s hovered observation, usually the same one drawn bolder or outlined, reading paint with the'hovered'state. Because positions come from the compiled observation, the highlight lands exactly over the base shape.renderHoverCompanionspaints hints on other layers at the hovered x. Returnnullwhen the observation is its own highlight.
Hit-testing layout geoms
Most geoms are hit-tested from their scaled positions; the compiler builds the index fromspatialKind. 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 { key }, the identity key of the observation under it, or null. It receives the same panelRect as render:
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, such as a force-directed graph, must own its pointer events. For that,render takes the live form: { fn, options: { live: true } }. It paints above the layer that handles the chart’s hover, so its marks get the pointer first, and it paints in the panel’s units like every other geom, so it scales with the graph. Its function receives input.live:
pushHover(key, cursor)feeds the central hover store;pushHover(null)clears it.readPanelPoint(event)converts a pointer’s client coordinates to panel units, so a dragged mark follows the pointer on a graph drawn at any size.
pushHover resolves against the same render-hit-test index, so the compile half declares spatialKind: 'render-hit-test' and an identity of its own here too:
spatialKind, the geom still paints but every pushHover resolves against no index (no hover, no tooltip) and the chart reports LIVE_RENDER_REQUIRES_RENDER_HIT_TEST.
A live render and a hitTest factory are alternatives, not a pair: the live render pushes hover from its own handlers, the factory answers the central pull. Declaring both is CONFLICTING_RENDER_HIT_TEST, and only the live render 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 live form wraps. Reach for it only when the live form doesn’t cover your case.
Related
- 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

