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 theGeom 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:xis 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 raisesUNSUPPORTED_POSITION.
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 itsdefaultStat. 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:
computedVariablesnames 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 withMISSING_STAT_OUTPUT, naming the stat.- The stat runs before grouping. Split the rows by
xand bygetGroupVariables(data, mapping), the same variables the layer groups on, as the built-incountdoes. A group column the output keeps stays mapped, socolor: 'region'still colors each span. - Consumed aesthetics are dropped. The rolled-up rows no longer hold
sales, so after the statyis no longer mapped. An aesthetic stays mapped only if the stat maps it or its column is still in the output. paramsare 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,
computeStatstill 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
UserInputErrorand 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. ImplementresolveAnchorPosition to answer, in normalized panel [0, 1] space:
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 ondataLabelCoordTypes and implement getDataLabelPlacements, which returns each label’s box center in panel pixels:
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
AstyleTarget 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:
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 throughdefineGeomRenderer, 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.Related
- Geom renderers — the render half,
defineGeomRenderer, hit-testing, hover - How a chart is built — where
compilesits in the pipeline - Scales — how declared positions get trained and mapped
- Diagnostics — what a misdeclared definition reports

