Heatmap
A 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.
import {
Heatmap,
HeatmapGrid,
HeatmapRowLabels,
HeatmapColumnLabels,
HeatmapLegend,
} from "@/components/ui/heatmap"
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
const hours = Array.from({ length: 24 }, (_, h) => `${h % 12 || 12}${h < 12 ? "am" : "pm"}`)
// traffic: 7 rows (days) of 24 numbers (hours)
export function TrafficHeatmap({ traffic }: { traffic: number[][] }) {
return (
<Heatmap data={traffic} rows={days} columns={hours} label="Visits by weekday and hour">
<HeatmapGrid>
<HeatmapRowLabels />
<HeatmapColumnLabels interval={3} />
</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.
<Heatmap
data={deploys}
rows={days}
columns={weeks}
// The grid's accessible name.
label="Deploys per day, last 12 months"
valueFormatter={(value) => `${value} deploys`}
>
{/* Each cell's name. The default is "row, column: value". */}
<HeatmapGrid cellLabel={(cell) => `${cell.formattedValue} on ${dateOf(cell)}`}>
<HeatmapRowLabels />
<HeatmapColumnLabels />
</HeatmapGrid>
</Heatmap>API reference#
Heatmap takes data (an array of rows, each an array of number | null), rows and columns (the labels), normalize (grid | row | column), domain (a fixed [low, high]), steps (default 4), color (default blue), size (sm | md), aspect (square | wide), valueFormatter, label (the grid’s accessible name), animate (the load reveal, on by default) and empty, and forwards div props. HeatmapGrid takes renderTooltip and cellLabel, both called with the cell (row, column, rowLabel, columnLabel, value, formattedValue, level). HeatmapRowLabels and HeatmapColumnLabels take tickFormatter and interval, like the Chart’s axes; HeatmapLegend takes lowLabel and highLabel. Every part accepts className, merged last, and carries a data-slot; cells also carry data-row, data-column and data-level.