Skip to main content
geom.tile() draws a rectangle that fills its cell on both axes. Where a bar encodes its value as a length, a tile encodes it as colour: the grid comes from a category on x and a category on y, and the value rides on the color aesthetic through a ramp. That makes it the tool for a matrix you want to read at a glance — cohort retention, revenue by product and region, activity by day and hour.

Basic example

Three columns: one per axis, one for the value. Both position scales are bands, and the value’s scale is a continuous colour ramp:
geom.tile() takes no params. A tile’s geometry is entirely decided by the two bands it sits in, so there is no width or radius knob to turn — see What the geom decides for the defaults it brings instead.

Data shape

A heatmap wants long format: one row per cell, carrying both coordinates and the value. Matrix data usually arrives wide instead — one column per column of the grid — so pipe transform.reshape in front of the mapping and map the columns it produces:

Color is the encoding

color is required on a tile layer, and the scale behind it is what makes the grid readable. On a tile the colour is the value, so with no color scale in the spec the engine infers one from the column: a numeric column gets the brand sequential ramp, and only a categorical one falls back to the ordinal palette — the waffle case, where the cells name a category rather than measuring one. Declare the scale to pick the ramp yourself:
For data that crosses zero, reach for a diverging scheme and pin its neutral stop with domainMid. Without the pin, the neutral colour drifts to the data’s midpoint — which is rarely zero — and the chart reads as though the break-even point moved:
Setting domainMid also turns on symmetric, so a +8% and a −8% cell get equal colour intensity. Continuous color covers the full option set — schemes, explicit ramps and interpolation spaces.

Gaps stay gaps

Only the rows you supply paint a cell. A grid with missing combinations leaves genuine holes rather than filling them with the ramp’s low end, keeping “absent” and “zero” visually distinct — and a hole answers no hover, because the hit-test index drops the same cells the renderer skipped:
To show every combination, emit a row for it — the axes’ band domains come from the values present in the data, so a category that appears nowhere gets no band at all. Pass domain on the scale to force the full set of bands: scale.x.discrete({ domain: ['Q1', 'Q2', 'Q3', 'Q4'] }).

Value labels

Data labels are on by default on a tile — a heatmap is usually read cell by cell, so the number belongs in the cell. Each label centres in its tile, and is dropped when the cell is too small to hold it. The ink flips between dark and light according to the fill it sits on, so labels stay legible at both ends of the ramp. Turn them off for a chart meant to be read as a texture:

What the geom decides

A tile carries defaults that a bar or a line wouldn’t want, so a heatmap looks right before you configure anything:

Hover, highlights and annotations

Hover hit-tests the cell the cursor is inside — the full band, including the inset around the painted tile, so the whole grid is live. The hovered cell gains an outline, and the tooltip pairs the cell’s x category as its header with a single row for the value the colour encodes. Highlights work as they do everywhere — a predicate over the post-transform columns, with matched cells staying vivid while the rest step back:
Annotations pinned to an observation address a cell with two values. On most geoms, anchorValue plus the group is enough; on a grid, anchorValue names a whole column, so crossValue names the band that picks one cell out of it:
An observation anchor resolves to the centre of its cell, and a pinned number prints the value the ramp encodes — on a tile that is the color value, not a length. A sticker centres on the cell too, so turn the cell labels off where the two would sit on top of each other. The kinds that take a panel anchor — text, arrows, shapes — float over the grid in fractions of the plot rect instead, free of any cell. Give text an opaque background: a ramp runs light to dark under it, so a transparent label is unreadable at one end or the other.
The two halves join through an annotation anchor: the arrow above runs from the caption’s box to a cell, so neither end is a hand-tuned fraction. The caption’s box is measured in the browser, so the tail re-flows when the text wraps differently or the plot resizes.

Limits

  • Cartesian only. coord.flip() and coord.polar() reject a tile layer with an UNSUPPORTED_COORD error. A heatmap has no orientation to flip: swap the two mappings instead.
  • identity position only. 'stack', 'dodge' and 'fill' raise UNSUPPORTED_POSITION — none of them mean anything without a value axis.
  • No paint vocabulary of its own. There is no style.geom.tile target: the fill comes from the colour scale, and the corner rounding and inset are fixed. style.geom({ alpha }) still applies, as do the hovered and dimmed states.
  • Scales — colour ramps, schemes and diverging midpoints
  • Transforms — reshaping wide matrix data to long
  • Data labels — the in-cell value labels
  • Highlights — emphasising the cells a predicate matches
  • Annotations — pinning a callout to one cell