import { HeatmapChart } from "@elabs-ai/components-charts";npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-chartsPaste 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
Used inside
Pairs with
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
| Prop | Type | Description |
|---|---|---|
messages | ChartMessages | messages 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. |
datarequired | Record<string, unknown>[] | One row per cell. Rows the grid has no place for are ignored. |
valueKeyrequired | string | Row key holding the number. A non-finite or missing value is an empty cell. |
xDataKey | string | Row 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. |
x | string | Row 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. |
yDataKey | string | Row 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. |
y | string | Row field holding the row value (discrete). Ignored by `variant="calendar"`. @deprecated Since 6.0.0, use `yDataKey`. Removed in 7.0.0. |
mode | HeatmapMode | How 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"`. |
variant | HeatmapVariant | Which 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. |
palette | HeatmapPalette | Which ordered ramp the values are drawn from. Default `"sequential"`. |
steps | number | How 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. |
showValues | boolean | @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. |
labels | boolean | ChartDataLabelsConfig | Print 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. |
highlight | HeatmapHighlight | Which 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) => boolean | Every 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. |
showValueHalo | boolean | Halo 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. |
emptyMarkScale | number | Side 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. |
emptyValue | HeatmapEmptyValue | What 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. |
xOrder | string[] | Column order. Defaults to first-seen order in `data`. |
yOrder | string[] | Row order. Defaults to first-seen order in `data`. |
cellRadius | number | Corner radius of a `mode="cell"` square, in px. Default 4 (L16). |
valueFormat | ChartValueFormat | How values are rendered in labels, the tooltip and the legend. Default `"compact"`. |
legend | boolean | Show the ramp key below the plot. Default `true`. |
showLegend | boolean | Show 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`. |
xAxisLabel | string | A 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. |
margin | number | Partial<HeatmapMargin> | Plot-area insets: one number for every side, or per side. Merged over the variant's own defaults. |
aspectRatio | string | Aspect ratio of the plot body. Default `"16 / 9"` (`"6 / 1"` for calendar). |
plotHeight | Responsive<ChartPlotHeight> | The plot's own height (ADR 0039): px, or `{ aspect }` (width ÷ height), optionally per breakpoint. |
revealOn | ChartRevealOn | When 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. |
status | ChartStatus | `"loading"` draws a layout-shaped skeleton instead of the data, until the data is `"ready"`. Default `"ready"`. |
empty | ChartEmptyState | What 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." }`. |
loading | boolean | Layout-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. |
emptyMessage | string | Supporting 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. |
emptyTitle | string | Title of the empty state. @deprecated Since 6.0.0, use `empty.title`. Removed in 7.0.0. |
emptyAction | ReactNode | An action for the empty state, rendered below the message. @deprecated Since 6.0.0, use `empty.action`. Removed in 7.0.0. |
accessibleLabel | ChartA11yProps["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`. |
accessibleDescription | ChartA11yProps["accessibleDescription"] | Supplemental description read after the label. |
className | string | |
style | CSSProperties | |
scrollbar | ChartCategoryNavigatorProps["scrollbar"] | Overview strip style. Default `"none"`. `"miniChart"` / `"bar"` / `"auto"` mount it once the categories overflow `maxVisibleItems`. |
selectionStates | ChartSelectionStatesResolver<Record<string, unknown>> | Maps a mark (category, optional series key, optional raw row) to its selection state. Unset → the chart renders exactly as before. |
dimExcluded | boolean | Dim `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. |
onDatapointClick | ChartDatapointClickHandler<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. |
copyValueOnActivate | boolean | Put 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. |
datapointLabel | ChartDatapointLabel<Record<string, unknown>> | Override the accessible name of each keyboard target. |
maxInteractiveDatapoints | number | Dev-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. |
selectionGestures | readonly ChartSelectionGesture[] | Gestures to enable. Unset → no gesture layer, DOM byte-identical to today. |
onSelectionIntent | ChartSelectionIntentHandler<Record<string, unknown>> | Fires with every intent. Required for the gesture layer to mount. |
selectionConfirm | ChartSelectionConfirm | Default `"immediate"`. |
selectionField | string | The field name carried in intents. Default: the chart's `xDataKey`. |
selectionHitRule | ChartSelectionHitRule | Rect / 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. |
window | NavigatorWindow | null | Controlled window. |
defaultWindow | NavigatorWindow | Initial window for the uncontrolled case. |
onWindowChange | (window: NavigatorWindow | null, meta: NavigatorChangeMeta) => void | |
minSpan | number | Smallest 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"`. |
maxVisibleItems | Responsive<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. |
zoom | boolean | Pinch-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. |
Theming
The tokens each state resolves to. Change the token and every theme follows.
| State | Token |
|---|---|
| cell | value → an ordered ramp step (--chart-seq-*, --chart-div-*, --chart-mono-* via resolvePalette) |
| empty | null or 0 → a QuietDot pinprick in --chart-foreground-muted, not a hole |
| peak | the highlighted cell → a dashed PeakRing in --chart-foreground |
| status | status="loading" → the same grid as skeleton cells in --muted |