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 the render half. 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 the same scales resolved its coordinates as every other geom’s.

Anatomy of a geom

Extend the Geom class, parameterised by its params type. Here is 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
required
Default values for the geom’s params, {} when it has none. The type parameter on Geom<Params> types both these and the params the builder accepts. The layer’s params are merged over them; override resolveParams for an invariant the merge can’t express.
readonly PositionRole[]
default:"[]"
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 recognized; required: true also enforces its presence.
GeomStyleTarget
The paint a stylesheet can address on this geom: observation for the layer’s own shapes, and one entry of parts per other piece the geom paints. Each names the properties it paints and its built-in look per state. Geometry stays in params; a style target is what a chart restyles without changing the shape. See what the geom paints.
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' | 'filled-buckets' | 'spanned-buckets' | 'rects' | 'cells' | 'points' | 'noop' | 'render-hit-test'
How the compiler builds the hover hit-test index from the geom’s scaled positions.
  • 'buckets': groups by x and joins each group’s observations along the layer, so the cursor snaps to the nearest x (lines).
  • 'filled-buckets': the same, and the band between each observation’s cross-axis bounds counts as painted (areas), so a cursor inside the fill is on the observation rather than snapped to its edge.
  • 'spanned-buckets': groups by x, but the stroke runs across each x rather than joining one to the next, so a press anywhere along it takes that x (a dumbbell’s connector, this lollipop’s stem). The stroke spans the x’s observations and any interval they declare, so a single observation still has length.
  • 'rects': rectangular bounds (bars).
  • 'cells': self-contained boxes read by containment, one observation each, with no band or stroke to snap along (tiles, heatmaps). Cartesian only; a bandless polar layer declares 'rects' and hit-tests as an enclosure (pie, donut) instead.
  • 'points': proximity to the shape itself (scatter).
  • 'noop': opts out of hit-testing (rules).
  • '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, which morphs and hover resolve against. 'x-group' derives it from the layer’s x + group columns (the standard cartesian case), 'x-y' from both position columns (a tile grid, where x repeats down a column with no group to tell rows apart), '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 scales: requireMainAxisBands forces a band on the main axis (what a bar needs), requireCrossAxisBands one on the cross axis (a tile), bandPadding the band’s padding, includeZero anchors the value scale at zero, and inferColorScale asks for a color scale when the layer maps one. Unset imposes none.
readonly AxisKey[]
default:"[]"
Lets categories have unequal widths. Declare ['x'] as const, map x to the category, and write each observation’s interval into xMin/xMax. Then:
  • x is required.
  • The x scale is continuous and trains on the intervals. It isn’t niced unless the spec sets nice.
  • Each observation sits at the center of its interval.
  • The axis ticks each category at its center.
  • Layers of the geom can’t use position: 'dodge', which would overwrite the intervals. It raises UNSUPPORTED_POSITION.
Only x is supported. Declaring y raises UNSUPPORTED_CATEGORY_SPAN_AXIS.
PositionAdjustment
default:"'identity'"
How overlapping observations of this geom arrange when the layer names no position: 'stack', 'dodge', 'fill', or 'identity' to leave them where they fall. Under 'dodge', x is the center of the observation’s share of the band and xMin/xMax its edges, so a geom drawn at x dodges with no extra work.
Stat | null
default:"null"
The stat a layer of this geom runs when it names none; null draws the data as-is (identity). Registering the geom registers the stat with it, so the kit also gains kit.stat.<type>(). A layer can name another stat, or identity to map already-summarized columns itself. See a summary is a stat.
readonly TooltipField[]
default:"[]"
The rows the hover tooltip reads off an observation. Each field is { aes, key?, heading? }, reading one aesthetic, or { range: [from, to], key?, heading? }, printing two as a range. key is the display label, and heading: true prints the field as the tooltip’s heading. The default is empty, so a geom that declares nothing shows no tooltip body.
HighlightStrategy | null
default:"null"
How the geom composes highlight matches above its base render: 'overlay-anchor' surfaces matched observations as a marker at the renderer’s getOverlayAnchor, 'observation-rerender' repaints the matched subset through the renderer’s renderHighlight (or render) above the dimmed layer, and null opts out. A geom names the strategy its renderer serves: an 'overlay-anchor' geom whose renderer has no getOverlayAnchor dims with nothing raised above it.

The compile step

compile(input) receives { data, mapping, params, coordType }: the layer’s post-stat Dataset, its effective mapping (which carries any custom positional aesthetics the geom declared), the resolved params and the spec’s coord type. It returns a GeomCompileResult, { 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. A geom whose shapes summarize many rows doesn’t summarize here either: the summary is a stat, so an author can replace it.

A summary is a stat

Some geoms draw a summary rather than the rows: a boxplot draws quartiles, a range bar the lowest and highest value per x-value. In the grammar that summary is a stat, and the geom names it as its defaultStat. A layer that names no stat runs it; a layer whose data is already summarized names identity and maps the summary columns itself. Here is a span, a bar from the lowest to the highest y of each x, whose stat computes the range:
The layer maps the raw values, and the stat turns them into the span:
What a stat gets and owes:
  • computedVariables names the aesthetics the stat maps, including a geom’s custom positional ones. Layer validation takes it at its word and doesn’t ask the author to map them, so the stat must deliver each one the geom requires, mapped to a column it writes. A stat that doesn’t fails with MISSING_STAT_OUTPUT, naming the stat.
  • The stat runs before grouping. Split the rows by x and by getGroupVariables(data, mapping), the same variables the layer groups on, as the built-in count does. A group column the output keeps stays mapped, so color: 'region' still colors each span.
  • Consumed aesthetics are dropped. The rolled-up rows no longer hold sales, so after the stat y is no longer mapped. An aesthetic stays mapped only if the stat maps it or its column is still in the output.
  • params are the layer’s params. One param list serves the geom and its stat, so a stat reads its settings where the author sets the geom’s: kit.geom.span({ params: { … } }). A geom and its default stat must not use one param name for two meanings.
  • The stat runs on an empty layer too. When every row is filtered out, computeStat still runs, so the aesthetics it maps stay mapped. An empty frame’s column types are placeholders, so check a column’s type only when there are rows.
  • Report a bad input with UserInputError and a user-input code, as the built-in stats do.

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 neighboring 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 that anchors every purpose to the same point, like the lollipop’s dot, 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.

Data labels

A geom places its own data labels. Declare the coords it places under on dataLabelCoordTypes and implement getDataLabelPlacements, which returns each label’s box center in panel pixels:
Simplified: dotBox stands in for converting the dot’s scaled position to a box in panel pixels. A SceneLayer’s data is a Dataset, which you iterate with for...of or spread into an array; it has no map. The context carries the formatters (formatLabel, formatAbsolute, formatCategory), so a label prints what the axis and the tooltip print without the geom formatting anything itself. measureDataLabel returns the themed box the placement must fit, and styleReaders answers the geom’s own paint, such as a dot’s size. resolveAnchoredLabelCenter resolves the author’s position, justify, align and offset against a geom’s box, so every geom means the same thing by them; 'auto' is the geom’s own heuristic to resolve before calling it. Each placed label carries its center, its size, its role and the position it resolved to. The hook runs only when the layer asks for a label of some kind, and reads which of showDataLabels, showStackTotals and showCategoryLabels it honors. The last two are bar vocabulary that most geoms ignore. Declaring the coords without implementing the hook raises MISSING_DATA_LABEL_PLACEMENT: the flag would be accepted and nothing would paint.

What the geom paints

A styleTarget declares the paint a stylesheet can address on the geom: observation for the layer’s own shapes, and one entry of parts per other piece the geom paints, such as the lollipop’s stem beside its dot. observation is the layer’s own paint. Like a built-in geom it reads the encoding, and it takes every geom entry that names no part. It paints the shared geom vocabulary (fill, stroke, alpha, fillAlpha, strokeAlpha and saturation) plus whatever it declares, so the lollipop above adds size and keeps the rest. sharedPaint: false declines the shared words, for a geom that speaks fewer of them. aesthetics names which aesthetic answers each property from the encoding; unset, color answers both fill and stroke and alpha answers alpha. A geom whose outline keeps a color of its own declares { fill: 'color', alpha: 'alpha' }, and a line-like geom { stroke: 'color', strokeAlpha: 'alpha' }. Each entry of parts declares everything it paints and takes only the entries that name it. A part does not read the encoding unless it declares aesthetics. A part that joins observations, such as a dumbbell’s connector, has no row to read from. A part painted once per observation, such as the lollipop’s stem, is painted for that observation, so aesthetics: { stroke: 'color' } gives the stem its dot’s color from the scale. rest, dimmed and hovered are the built-in look in each state, checked against the vocabulary they belong to. rest is what paints when nothing is happening to the geom. The shared geom defaults, the house color and the dimmed alpha included, reach only the observations. The kit types the geom’s own style builder from the observation vocabulary, and adds one per part:
The render half reads all of it through styleReaders; see a geom with several parts.

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.