Skip to main content

HeatmapChart

Distributionchart

Two discrete dimensions × one value — weekday × hour, product × region, or a year of days.

Colour scale: 5 steps from 0 to 63.zero
import { HeatmapChart } from "@elabs-ai/components-charts";
Install
npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-charts

Skipping the @source line is the most common first failure: Tailwind does not scan node_modules, so every component renders unstyled. The one-time wiring

Prompt for your coding agent

Paste it into Claude Code, Cursor, Codex or any other coding agent. Text in [brackets] is yours to fill in.

Add the brand-ui HeatmapChart chart (from @elabs-ai/components-charts) to this project.

1. Read https://elabs-ai.com/llms.txt first: it maps every package and the rules. If you can use MCP servers, connect https://elabs-ai.com/mcp and use its search, docs and tokens tools. If not, run the same lookups in the terminal: `npx -y @elabs-ai/components-cli search "<what you need>"` and `npx -y @elabs-ai/components-cli docs <Component>`.
2. Look HeatmapChart up before writing code: `npx -y @elabs-ai/components-cli docs HeatmapChart` prints its real props, variants, what it pairs with and what to avoid.
3. If @elabs-ai/components-charts is not installed yet: `pnpm add @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-charts` (or `npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-charts`). If this is the first brand-ui package in the project, wire it once: import `@elabs-ai/components-tokens/styles.css` plus the `themes/light.css` and `themes/dark.css` stylesheets in the CSS entry, add one Tailwind `@source` line per installed @elabs-ai package (Tailwind does not scan node_modules, so without it everything renders unstyled), and wrap the app root in `ThemeProvider` from `@elabs-ai/components-tokens`.
4. Use it here: [the screen or file, and what it should show]
5. Rules: use only @elabs-ai/components-* components and the semantic tokens — no raw colours, no raw font sizes, no arbitrary Tailwind values. Look up each component's real props with the docs tool before you use it; never guess a prop.
6. Done means: the typecheck passes, `npx -y @elabs-ai/components-cli audit src --strict` reports nothing, and you have opened the app and looked at every screen you touched in the light and the dark theme.

Use it for

  • How is it spread? Category by category.
  • two categorical axes (weekday by hour, for example) with one numeric value per cell — ticket volume, event counts; many small cells favour mode="dot" over the default cell fill
  • one measure per calendar day over several months, as variant="calendar"

Avoid

  • more than about 10 columns of continuous data, or exact cell values matter more than the pattern
  • steps: 0 (the continuous ramp) when the reader must compare exact values — opacity is not countable; keep the stepped ramp or turn labels on.
  • emptyValue="blank" on data that contains real zeroes — a measured zero then looks identical to a missing row, which is the one thing the pinprick exists to prevent.
  • Forcing the calendar variant into a narrow box (overflow-hidden, a tall aspectRatio) — it enforces a minimum width and scrolls on purpose; squeezed day cells fall below the 24px target size.
  • Leaning on the darkest ramp step to say "this is the peak" — colour alone; highlight draws the ring, and a diverging palette needs labels or its negative hatch for sign.

Works with

Pairs with

Examples

13 live examples

Matrix

The punch card: 7 weekdays × 24 hours, 168 cells, five countable shade steps (L16). Measured zeroes draw a pinprick, keyed “zero” in the legend; a cell with no value would draw an outline instead (see `ZeroVersusMissing`).

Matrix With Values

G20: a small enough matrix that every cell can carry its own number.

Legend Range Labels

`legendLabels="ranges"` (RM-118): a `from–to` label under every swatch instead of just the strip's `lo`/`hi` endpoints — lets a reader place a cell in its step without hovering it. Five countable steps (the default), so the legend prints five swatches and five range labels.

Dot Heat

F10: the value is the dot's AREA, not its radius — so a doubled number draws a dot √2 wider, never twice as wide. The shade tracks the same ramp, so the two encodings agree instead of competing.

Calendar

L17: a year of deploys as 53 week columns × 7 weekday rows, with a month label above the week that holds each month's first Monday. A weekend with no deploy is a pinprick, not a gap.

Diverging

Week-over-week change. A diverging ramp is lightness-symmetric by construction, so in greyscale a `+18` and a `-18` are the same colour — this chart therefore always carries a second, non-hue channel for sign: the value labels are on by default, and turning them off hatches the negative cells instead (see `DivergingHatched`).

Diverging — hatched (labels off)

The same data with the labels off — sign then rides a 45° hatch.

Zero versus missing

Zero is not missing. With the labels off, a measured `0` is a pinprick and a cell with no value is a hairline outline — two shapes, two legend keys, so a reader can tell “flat” from “never measured” without colour or numbers.

Loading

The layout-shaped skeleton: the same grid, so nothing shifts when data lands.

Empty

No rows at all — an empty state, never an empty grid pretending to be data. It fills the same 16 / 9 plot box a loaded chart does, so a filter that empties the grid does not collapse the page under the reader’s cursor.

Drilldown

Click a cell to drill into it. The pointer path activates the CELL itself; the layer's `<button>`s are `pointer-events: none` on purpose — they exist so a keyboard user reaches what a mouse user can click, without adding a second hit target over the chart. So this story clicks the mark, and `KeyboardDrilldown` drives the buttons.

Keyboard Drilldown

The same drill-down with the keyboard only: one tab stop for the whole grid, arrows to traverse it, Enter to activate.

Selection states

`selectionStates` paints the host’s tri-state on cells (keyed on the column category): selected marks carry a compound foreground/background outline, excluded marks dim AND carry a full-opacity dashed frame, so the three states stay distinguishable in greyscale.

API

HeatmapChart

PropTypeDescription
messagesChartMessagesmessages group (RM-187): this chart's own words, keyed by the ui catalogue's `charts.*` message keys. A key set here wins over the `LocaleProvider`; every other key reads the catalogue as before.
datarequiredRecord<string, unknown>[]One row per cell. Rows the grid has no place for are ignored.
valueKeyrequiredstringRow key holding the number. A non-finite or missing value is an empty cell.
xDataKeystringRow field holding the column value (discrete; an ISO date in the calendar variant). Passing neither this nor the deprecated `x` still renders — every row collapses onto one unnamed column — and logs one development warning naming `xDataKey`. Required from 7.0.0.
xstringRow field holding the column value (discrete; an ISO date in the calendar variant). @deprecated Since 6.0.0, use `xDataKey`. Removed in 7.0.0.
yDataKeystringRow field holding the row value (discrete). Ignored by `variant="calendar"`. Passing neither this nor the deprecated `y` still renders on `variant="matrix"` (every row collapses onto one unnamed row) and logs one development warning naming `yDataKey`. Required on `variant="matrix"` from 7.0.0.
ystringRow field holding the row value (discrete). Ignored by `variant="calendar"`. @deprecated Since 6.0.0, use `yDataKey`. Removed in 7.0.0.
modeHeatmapModeHow a cell encodes its value. - `"cell"` — a filled square whose SHADE is the value (L16, G20). - `"dot"` — a dot whose AREA is the value (F10, L4, L17); the shade still tracks the ramp, so the two encodings are redundant, not rival. Default: `"cell"` for `variant="matrix"`, `"dot"` for `variant="calendar"`.
variantHeatmapVariantWhich grid the cells land on. `"calendar"` reads `xDataKey` as an ISO date, lays the days out as 7 weekday rows × one column per ISO week, ticks the first Monday of each month, and ignores `yDataKey` entirely.
paletteHeatmapPaletteWhich ordered ramp the values are drawn from. Default `"sequential"`.
stepsnumberHow many countable ramp steps. Default {@link DEFAULT_HEATMAP_STEPS}. `0` asks for a continuous ramp — one hue at a continuously varying opacity, because the ramp entries are `var()` references nothing can interpolate.
showValuesboolean@deprecated Use `labels` — `true`/`false` keep meaning the same thing (ADR 0042 A.3, row 14). Read until 7.0.0, with one development warning; when both are set, `labels` wins.
labelsboolean | ChartDataLabelsConfigPrint each cell's value on it, as halo text (G20). Default `false`, except on `palette="diverging"` where it defaults to `true` — see decision 3 in the module docblock for why sign cannot ride on hue alone.
highlightHeatmapHighlightWhich cell gets the dashed peak ring. Default `"max"`, the lieflat convention; the ringed cell is also the one the accessible summary names.
rowHighlight(rowLabel: string) => booleanEvery row this predicate matches (by its \`y\` label) gets a dashed rail around the whole row plus a bold row label -- emphasis for "the finding is about this ROW", not just one cell in it (#280). Shape + weight, never hue alone (WCAG 1.4.1). Default \`undefined\` -- no row emphasis.
showValueHalobooleanHalo behind a \`mode="cell"\` value label. Default \`true\`. A flat filled plate already carries its own on-mark ink pair (#238); set \`false\` when that alone reads clean and a halo would ring-fringe a mid-tone fill (#280) -- never needed for \`mode="dot"\`, where the label sits on the busier plot ground.
emptyMarkScalenumberSide of the no-data outline, as a fraction of the cell's shorter side. Default {@link DEFAULT_EMPTY_MARK_SCALE}. Shrink it for a denser grid or a deliberately quieter empty state (#280); the legend's swatch is a KEY at its own fixed size and does not scale with this.
emptyValueHeatmapEmptyValueWhat a cell with nothing to shade draws. `"quiet"` (default) keeps the two facts apart: a measured `0` is the 0.9px pinprick (measured, and the answer was nothing), a `null` is a hairline outline of the empty cell (never measured). Each gets its own legend key, shown only when the grid holds one. `"blank"` draws neither.
xOrderstring[]Column order. Defaults to first-seen order in `data`.
yOrderstring[]Row order. Defaults to first-seen order in `data`.
cellRadiusnumberCorner radius of a `mode="cell"` square, in px. Default 4 (L16).
valueFormatChartValueFormatHow values are rendered in labels, the tooltip and the legend. Default `"compact"`.
legendbooleanShow the ramp key below the plot. Default `true`.
showLegendbooleanShow the ramp key below the plot. @deprecated Since 6.0.0, use `legend` (the same boolean). Removed in 7.0.0.
legendLabels"endpoints" | "ranges"How the legend key states the scale — the same `"ranges"`/`"endpoints"` vocabulary `RampLegend`'s `scale.labels` uses (RM-118). `"endpoints"` (default, unchanged): `lo`/`hi` bracket the strip. `"ranges"`: one `from–to` label under every swatch, so a reader can place a cell in its step without hovering it — most useful together with a small `steps`.
xAxisLabelstringA visible title for the column axis (#280), e.g. "Months since signup" — printed directly under the plot, ABOVE the legend, so it reads as the axis's own caption rather than a floating sentence after the key. Default `undefined` (no title printed). Not a substitute for `accessibleLabel`/ `accessibleDescription`, which already state the axis in prose for AT.
marginnumber | Partial<HeatmapMargin>Plot-area insets: one number for every side, or per side. Merged over the variant's own defaults.
aspectRatiostringAspect ratio of the plot body. Default `"16 / 9"` (`"6 / 1"` for calendar).
plotHeightResponsive<ChartPlotHeight>The plot's own height (ADR 0039): px, or `{ aspect }` (width ÷ height), optionally per breakpoint.
revealOnChartRevealOnWhen the enter stagger plays (RM-020). `"mount"` (default) plays as soon as the chart renders; `"inView"` holds every cell hidden until the plot scrolls into view, then plays once.
statusChartStatus`"loading"` draws a layout-shaped skeleton instead of the data, until the data is `"ready"`. Default `"ready"`.
emptyChartEmptyStateWhat the chart shows when there is nothing to plot: a `title`, a supporting `message`, and an `action` rendered below the message — typically the control that undoes the filter which emptied the grid. Each key you leave out keeps its default. Default `{ title: "No data", message: "No data to plot." }`.
loadingbooleanLayout-shaped skeleton instead of the data. @deprecated Since 6.0.0, use `status` — `loading={true}` is `status="loading"`, `loading={false}` is `status="ready"`. Removed in 7.0.0.
emptyMessagestringSupporting sentence of the empty state, shown when there is nothing to plot. @deprecated Since 6.0.0, use `empty.message`. Removed in 7.0.0.
emptyTitlestringTitle of the empty state. @deprecated Since 6.0.0, use `empty.title`. Removed in 7.0.0.
emptyActionReactNodeAn action for the empty state, rendered below the message. @deprecated Since 6.0.0, use `empty.action`. Removed in 7.0.0.
accessibleLabelChartA11yProps["accessibleLabel"]Accessible name for the chart region. Defaults to a generated summary ("Heatmap, 7 rows × 24 columns, peak 42 at Wed 14:00.") — passing one overrides it. The generated sentence is already localized: its words are the `charts.heatmap.*` messages and its numbers follow the `LocaleProvider`.
accessibleDescriptionChartA11yProps["accessibleDescription"]Supplemental description read after the label.
classNamestring
styleCSSProperties
scrollbarChartCategoryNavigatorProps["scrollbar"]Overview strip style. Default `"none"`. `"miniChart"` / `"bar"` / `"auto"` mount it once the categories overflow `maxVisibleItems`.
selectionStatesChartSelectionStatesResolver<Record<string, unknown>>Maps a mark (category, optional series key, optional raw row) to its selection state. Unset → the chart renders exactly as before.
dimExcludedbooleanDim `excluded` marks and add their non-hue channel (the dashed frame). Default `true`; `false` keeps the `data-selection` attribute (so a host can style it) but paints nothing.
onDatapointClickChartDatapointClickHandler<Record<string, unknown>>Fires when a datapoint is activated by pointer OR keyboard. Setting it is what enables the interaction layer — with it unset the chart renders exactly as before, with no extra DOM and no new focusables.
copyValueOnActivatebooleanPut the datapoint's EXACT value on the clipboard when it is activated — the recovery path for compact axis labels and tooltips, reachable by mouse AND keyboard because activation rides `ChartDatapointLayer`'s real buttons. Default `false` on a raw chart container: turning it on mounts the interaction layer, and the documented guarantee is that a chart with no interaction props renders byte-identical DOM. `AutoChart` — the spec-driven surface, where the caller never sees the axis config — turns it on by default. A consumer-supplied `onDatapointClick` always wins; the two never both run.
datapointLabelChartDatapointLabel<Record<string, unknown>>Override the accessible name of each keyboard target.
maxInteractiveDatapointsnumberDev-warning threshold on the number of keyboard targets. Default 500. NOT a functional cap — every plotted point stays reachable, so a keyboard user can reach exactly what a mouse user can click.
selectionGesturesreadonly ChartSelectionGesture[]Gestures to enable. Unset → no gesture layer, DOM byte-identical to today.
onSelectionIntentChartSelectionIntentHandler<Record<string, unknown>>Fires with every intent. Required for the gesture layer to mount.
selectionConfirmChartSelectionConfirmDefault `"immediate"`.
selectionFieldstringThe field name carried in intents. Default: the chart's `xDataKey`.
selectionHitRuleChartSelectionHitRuleRect / lasso hit rule. Default `"overlap"` (the report-builder BI suite).
selectionToolbar"auto" | "none"`"auto"` (default): a toolbar appears when gestures are enabled; `"none"` hides it.
windowNavigatorWindow | nullControlled window.
defaultWindowNavigatorWindowInitial window for the uncontrolled case.
onWindowChange(window: NavigatorWindow | null, meta: NavigatorChangeMeta) => void
minSpannumberSmallest window the user can make: a duration in ms (time) or a row count (index). Default: 5× the median step (time; the stock-chart library' rule) / 3 (index).
align"start" | "end"Where an automatic first window sits. `"end"` = the latest data (the associative BI suite `scrollStartPos: 1`). Default `"start"`.
maxVisibleItemsResponsive<number | "auto">Category families (RM-141): how many categories the plot shows at once (the associative BI suite "Number of bars"). Default `"auto"` — as many as keep a readable band (`maxReadableCategories`); with `scrollbar="auto"` the strip appears only once the categories overflow that count.
windowDomain"all" | "visible"Category families: `"all"` (default) keeps the value axis on the FULL data's domain while scrolling; `"visible"` refits it to the window.
zoombooleanPinch-to-zoom along the x axis (time x): two fingers on a touch screen, a trackpad pinch, or Ctrl/⌘ + wheel narrow the same window the strip moves; a two-finger drag pans it. `+` / `−` / `0` on the focused chart and the zoom buttons that appear once zoomed do the same from the keyboard. No strip is needed; with one, it follows. Default `true`; `false` leaves the gestures to the page. Off while the caller drives `xDomain` itself.

Also accepts: ChartSelectionProps, ChartInteractionProps, ChartSelectionGestureProps, FrameSizeGroupProps, ChartStateGroupProps, ChartCategoryNavigatorProps

Theming

The tokens each state resolves to. Change the token and every theme follows.

StateToken
cellvalue → an ordered ramp step (--chart-seq-*, --chart-div-*, --chart-mono-* via resolvePalette)
emptynull or 0 → a QuietDot pinprick in --chart-foreground-muted, not a hole
peakthe highlighted cell → a dashed PeakRing in --chart-foreground
statusstatus="loading" → the same grid as skeleton cells in --muted