Skip to main content
A custom geom is a new kind of shape a chart can draw. It has two halves: a compile half — a Geom definition that declares the geom’s aesthetics and places its observations in data space — and a render half that paints them. This page covers the compile half; the geom renderers page covers paint. The guiding rule: the compiler owns every position, the renderer invents none. Your geom declares which position roles it needs, writes their values in data units, and the compiler scales them onto the axes. An observation then stays anchored through any domain change — zoom, a log scale, a flipped coordinate — because its coordinates were resolved by the same scales as every other geom.

Anatomy of a geom

Extend the Geom class, parameterised by its params type. Here’s a lollipop — a dot on a stem dropped to the baseline:

What the definition declares

string
required
The geom’s name, written as const. It becomes the typed builder method (kit.geom.lollipop) and the renderer registry key.
object
Default values for the geom’s styling params. The type parameter on Geom<Params> types both these and the params the builder accepts.
readonly PositionRole[]
required
The positions the geom occupies, each { axis, role }. A 'point' role is a single coordinate on its axis and sources that axis aesthetic implicitly — createSpec({ x }) satisfies { axis: 'x', role: 'point' }, with no aes on the role. 'min' / 'max' form an interval (like a bar or area); 'scalar' is a magnitude. A min / max / scalar role may name a custom positional aes — the lollipop’s max role is exposed as the y aesthetic. Write the array as const so the typed builder can constrain aes to exactly these aesthetics.
readonly GeomAesthetic[]
The visual and data aesthetics the geom reads beyond position — here, color. Each is { kind, name, required? }, with kind one of 'visual' (trained through a visual scale, like color or size) or 'data' (a raw input a layout geom hands straight to its algorithm, unscaled). Declaring a name registers it so the mapping is recognised; required: true also enforces its presence.
readonly CoordType[]
default:"['cartesian', 'flip']"
Which coordinate systems the geom compiles under, drawn from the spec-level union 'cartesian' | 'polar' | 'flip'. The lollipop narrows the default to cartesian alone. This union is the authoring one — the render half keys its contracts on 'cartesian' | 'polar', where a flipped chart is a cartesian variant. See the render contract.
'buckets' | 'rects' | 'points' | 'noop' | 'render-hit-test'
How the compiler builds the hover hit-test index from the geom’s scaled positions. 'buckets' groups by x (lines, areas), 'rects' uses rectangular bounds (bars), 'points' uses point proximity, 'noop' opts out. Use 'render-hit-test' for a layout geom whose geometry the compiler can’t see.
IdentityKey
default:"'x-group'"
What makes “the same observation” across recompiles — the key morphs and hover resolve against. 'x-group' derives it from the layer’s x + group columns (the standard cartesian case), 'index' from position in the dataset, and { variable: 'nodeId' } names a column the geom owns. A geom whose observations aren’t x-and-group keyed must say so: a 'render-hit-test' geom left on the default raises RENDER_HIT_TEST_IDENTITY and its hover lookup is built empty.
ScaleConstraints
Domain constraints the geom imposes on the inferred position scales: discreteMainAxis forces a categorical band on the main axis (what a bar needs), zeroBaseline anchors the value scale at zero. Unset imposes none.
PositionType
default:"'identity'"
How overlapping marks of this geom arrange when the layer names no position — 'stack', 'dodge', 'fill', or 'identity' to leave them where they fall.
readonly { key, aes }[]
default:"[]"
The rows the hover tooltip reads off an observation, each pairing a display key with the aes it reads. The default is empty, so a geom that declares nothing shows no tooltip body.
HighlightStrategy | null
default:"'overlay-anchor'"
How the geom composes highlight matches above its base render: 'overlay-anchor' surfaces matched observations as a marker at the geom’s own anchor, 'observation-rerender' repaints the matched subset in place, null opts out. Highlight composition resolves per built-in geom, so a custom geom’s layers carry none either way; declaring null states that plainly.

The compile step

compile(input) receives { data, mapping, params } — the layer’s post-stat Dataset, its effective mapping (which carries any custom positional aesthetics the geom declared), and the resolved params — and returns a CompiledGeom, { data, mapping }. This is where the geom writes any positions it derives rather than reads from the mapping. The lollipop’s baseline isn’t in the data, so compile adds it as a constant column in data units; the compiler then scales yMin and yMax through the shared y-scale like any other position. A geom with 'data'-kind aesthetics reads mapping here: those columns never pass through a scale, so the algorithm has to fetch them itself. Do the minimum here: derive positions, and leave scaling, stacking, and axis placement to the pipeline. A geom that computes screen pixels in compile has crossed the compile/render seam.

Anchoring annotations

Annotations attach to an observation, and the geom is what knows where that observation sits. Implement resolveAnchorPosition to answer, in normalized panel [0, 1] space:
Assign it on the definition — override readonly resolveAnchorPosition = resolveLollipopAnchor;. The second argument is an AnchorContext, not a bare coord system: { coordSystem, position, purpose, align? }. position is the layer’s resolved position adjuster — the sole authority on whether the value columns hold cumulative stack bounds. purpose is 'pin' (something sits on the observation, like a comment, so it moves off an edge shared with a neighbouring segment) or 'value' (something reads the value off the axis, like a difference arrow, so it stays on the outer edge). align names a point of the geom’s box when the anchor asks for one. A geom whose observations carry no extent — the lollipop’s dot — resolves every purpose to the same place and can ignore all three. The annotation stage skips geoms that don’t implement the hook, so their observations can’t be annotated; on a 'render-hit-test' geom the omission also raises MISSING_ANCHOR_CAPABILITY.

Further declarations

The base class defaults everything else, and a geom overrides only what differs:

Registering and using it

The compile half pairs with a render half through defineGeomRenderer, and the paired result goes into the plugins array. From there the typed builder method appears, constrained to exactly the aesthetics and params the definition declared:
A native spec doesn’t auto-infer position scales the way the chart-type recipes do — declare scale.x() and scale.y() explicitly, or the positions resolve to NaN. See Scales.