Heatmap
ProA grid of cells tinted by value, for activity by hour, a year of days or a retention cohort. Scale it per grid, row or column; every cell reads out on hover, on focus and to screen readers.
Installation#
Usage#
Heatmap is composed from named parts, like Chart. The root takes the data (one array per row), the rows and columns labels and the colour scale. HeatmapGrid draws the cells; HeatmapRowLabels and HeatmapColumnLabels go inside it so they line up with the cells, and HeatmapLegend is the Less / More key.
The preview is yours to explore. Its code comes with a Pro license, paid once and used in every project.
Have a license? Unlock it here
Anatomy#
import {
Heatmap,
HeatmapGrid,
HeatmapRowLabels,
HeatmapColumnLabels,
HeatmapLegend,
} from "@/components/ui/heatmap"
<Heatmap>
<HeatmapGrid>
<HeatmapRowLabels />
<HeatmapColumnLabels />
</HeatmapGrid>
<HeatmapLegend />
</Heatmap>Activity card#
The dashboard widget: one row per event, one column per day, in a Card. Visits and abandonments are a thousand times apart, so each row is shaded against itself (normalize="row"), and the tooltip adds the date the column header leaves out through renderTooltip. Switch the week: the cells tween to their new shades instead of redrawing.
Normalization#
normalize decides what a cell’s shade is measured against. grid (the default) puts every cell on one scale, the honest reading when the rows share a unit and a magnitude. row shades each row from its own quietest cell to its busiest, for rows that differ by orders of magnitude; column does the same down each column. The tooltip and each cell’s accessible name always carry the real number, whatever the shade.
Calendar strip#
A year of days, one column per week. size="sm" tightens the gaps and lowers the cell minimum, so 53 weeks fit a card; on a phone the strip scrolls sideways inside its own box while the day labels stay pinned. null marks the days before the range starts (a blank, not a zero). tickFormatter prints each month once and every other weekday, and cellLabel names each cell by its real date. The legend is a part like any other, so it can share a row with a summary.
Retention cohort#
The share of each signup cohort still active week by week. Newer cohorts have lived fewer weeks, so the weeks still to come are null and the grid reads as a triangle. domain={[0, 100]} pins the scale to the whole percentage range instead of the data’s own, so a cell’s shade means the same thing in every report, and steps={6} gives the scale more shades to tell the weeks apart.
Colors#
The scale is one hue mixed into the muted track: an empty cell is the track itself and each step adds more of the hue until the last is the hue at full strength. The hues are the Chart’s: blue (the default, the house chart hue), brand for a heatmap that is the story of the screen, primary, the accents, the status roles, and current to build the scale from the ambient text colour. Every hue re-themes across light, dark, cream and moonlight.
Steps#
steps sets how many shades sit above the empty track (four by default, up to ten). Fewer steps read faster and hide small differences; more steps show them. The legend always paints the scale the grid uses.
Size#
Cells always share the width of the box, down to a minimum. md (the default) keeps them at least 24px with 4px gaps, room for a three-letter label over every column in a dashboard card; sm goes down to 10px with 2px gaps, for a dense strip. Below the minimum the grid scrolls sideways inside its own box, never the page.
Aspect#
Cells are square by default, so a many-column grid keeps its proportions. aspect="wide" holds one row height (28px at md, 12px at sm) and lets the cells stretch across their column, for a few columns in a lot of width, where squares would make a tower.
Only the parts you need#
Every part is optional. Leave out the legend for a tight card, or the labels too for a bare strip beside a figure: the grid takes back the room they would have used, with no gutter left behind. The tooltip is a render prop on HeatmapGrid; return null to go without one.
Load animation#
Like the Chart, a heatmap plays a one-shot reveal when it mounts: the cells fade and grow in a diagonal wave from the top-left corner. It never replays on hover or when the data changes (a changed cell tweens its shade instead), and it holds still under prefers-reduced-motion. Pass animate={false} to opt out. Press Replay to watch it again.
Empty state#
With no rows or no columns, HeatmapGrid shows a muted message in place of the cells and the labels and legend step aside. Pass empty on the root to say something more useful. A grid of zeros is not empty: it paints the muted track, which is the truth.
Accessibility#
The cells are an ARIA grid named by label. It is one tab stop: the arrow keys move between cells (skipping blanks), Home and End jump to the ends of a row, and Ctrl+Home and Ctrl+End to the first and last cell. Focus opens the same tooltip as hover. Every cell carries its full reading as its name (“Sign-ups, Mon: 131”), so the row and column labels are decorative duplicates hidden from assistive tech, and the legend is too. Write a cellLabel when the labels alone do not say what a cell is, like a calendar day.
The preview is yours to explore. Its code comes with a Pro license, paid once and used in every project.
Have a license? Unlock it here
API reference#
Heatmap
The root: owns the data, the labels and the colour scale, and shares them with the parts through Context. Forwards all native <div> props except color; every part accepts className, merged last.
| Prop | Type | Default | Description |
|---|---|---|---|
| data* | ReadonlyArray<ReadonlyArray<number | null | undefined>> | - | One array per row, one entry per column. null, a missing entry or a non-finite number is a blank cell; zero paints the muted track. |
| rows | readonly string[] | - | Row labels, in order; used by HeatmapRowLabels, the tooltips and each cell's name. Missing ones read Row 1, Row 2... |
| columns | readonly string[] | - | Column labels, in order; missing ones read Column 1, Column 2... Its length also sets the minimum column count. |
| normalize | "grid" | "row" | "column" | "grid" | What each shade is measured against: the whole grid, its own row, or its own column. |
| domain | readonly [number, number] | - | Pin the scale to [low, high] (say [0, 100] for percentages); normalize then has no effect. |
| steps | number | 4 | Shades above the empty track, rounded and clamped to 1 through 10. |
| color | "blue" | "brand" | "primary" | "purple" | "pink" | "teal" | "orange" | "success" | "warning" | "info" | "destructive" | "current" | "blue" | The hue the scale is built from; current takes the ambient text colour. |
| size | "sm" | "md" | "md" | md: cells at least 24px, 4px apart. sm: at least 10px, 2px apart, for a dense strip. |
| aspect | "square" | "wide" | "square" | wide keeps one row height (28px at md, 12px at sm) and stretches cells across their column. |
| valueFormatter | (value: number) => string | - | Formats a value for the tooltip and each cell's name; defaults to en-US grouping with up to two decimals. |
| label | string | - | Accessible name of the grid, for example "Events per day, Sep 21 to Sep 27". Strongly recommended. |
| animate | boolean | true | Play the one-shot diagonal reveal on mount; held still under prefers-reduced-motion. |
| empty | ReactNode | "No data to display" | Shown in place of the cells when data has no rows or no columns. |
| Data attribute | Description |
|---|---|
| data-size | "sm" or "md". |
| data-aspect | "square" or "wide". |
| CSS variable | Description |
|---|---|
| --heatmap-color | The scale's hue, set by color; override it for a hue outside the list. |
HeatmapGrid
The scroll box and the ARIA grid of cells, with one shared tooltip that glides between them. Holds HeatmapRowLabels and HeatmapColumnLabels; forwards all native <div> props.
| Prop | Type | Default | Description |
|---|---|---|---|
| renderTooltip | (cell: HeatmapCell) => ReactNode | - | The tooltip body for a cell, composed from the Tooltip graph parts; return null for no tooltip. Defaults to row label, column label and the formatted value. |
| cellLabel | (cell: HeatmapCell) => string | - | Each cell's accessible name. Defaults to "row, column: value" ("Sign-ups, Mon: 131"). |
| Data attribute | Description |
|---|---|
| data-row | On each cell: its row index, from 0. |
| data-column | On each cell: its column index, from 0. |
| data-level | On each cell with a value: its scale step, 0 (the track) to steps. |
| data-blank | On each cell with no value. |
| CSS variable | Description |
|---|---|
| --heatmap-alpha | Per cell: the share of the hue mixed into the muted track, as a percentage. |
| --heatmap-cell-min | The narrowest a column gets before the grid scrolls (set by size). |
| --heatmap-rows | Row count, set inline on the layout. |
| --heatmap-columns | Column count, set inline on the layout. |
HeatmapRowLabels
The labels down the start edge, pinned while a wide grid scrolls; place it inside HeatmapGrid. Hidden from assistive tech, painted on --surface (falling back to --background); forwards all native <div> props.
| Prop | Type | Default | Description |
|---|---|---|---|
| tickFormatter | (label: string, index: number) => ReactNode | - | Format a label, or return null to leave its slot empty. |
| interval | number | 1 | Show every Nth label only, to thin out a crowded axis. |
| Data attribute | Description |
|---|---|
| data-active | On the label of the hovered or focused cell's row. |
HeatmapColumnLabels
The labels across the top; place it inside HeatmapGrid. A label followed by an empty slot spills from its column's start edge. Hidden from assistive tech; forwards all native <div> props.
| Prop | Type | Default | Description |
|---|---|---|---|
| tickFormatter | (label: string, index: number) => ReactNode | - | Format a label, or return null to leave its slot empty. |
| interval | number | 1 | Show every Nth label only, to thin out a crowded axis. |
| Data attribute | Description |
|---|---|
| data-active | On the label of the hovered or focused cell's column. |
| data-spill | On a label whose next slot is empty, so it marks its column from the start edge. |
HeatmapLegend
The scale from the empty track to the full hue. Hidden from assistive tech; forwards all native <div> props.
| Prop | Type | Default | Description |
|---|---|---|---|
| lowLabel | ReactNode | "Less" | Before the scale; null drops it. |
| highLabel | ReactNode | "More" | After the scale; null drops it. |
| Data attribute | Description |
|---|---|
| data-level | On each swatch: its step, 0 to steps. |