Heatmap

Pro

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#

# One-time setup (tokens + lib helpers)npx koalaui-cli@latest init# Activate your Pro license (once per machine)npx koalaui-cli@latest login <key># Add this component (its dependencies come along)npx koalaui-cli@latest add heatmap

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.

This example is part of Koala UI Pro

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.

User activity
20,127 events

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.

normalize="grid": one scale for every cell: only the visitor rows register

normalize="row": each event against its own quietest and busiest day

normalize="column": each day against itself, event by event

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.

969 deploys in the last year

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.

color="blue"

color="brand"

color="primary"

color="purple"

color="pink"

color="teal"

color="orange"

color="success"

color="warning"

color="info"

color="destructive"

color="current" + text-foreground

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.

steps={3}

steps={4} (default)

steps={7}

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.

size="md" (default)

size="sm"

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.

aspect="square" (default)

aspect="wide"

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.

Legend dropped

Labels and legend dropped: a bare strip

Tooltips dropped: renderTooltip returns null

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.

Default

No data to display

Custom message

No deploys in this period yet

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.

This example is part of Koala UI Pro

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.

PropTypeDefaultDescription
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.
rowsreadonly string[]-Row labels, in order; used by HeatmapRowLabels, the tooltips and each cell's name. Missing ones read Row 1, Row 2...
columnsreadonly 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.
domainreadonly [number, number]-Pin the scale to [low, high] (say [0, 100] for percentages); normalize then has no effect.
stepsnumber4Shades 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.
labelstring-Accessible name of the grid, for example "Events per day, Sep 21 to Sep 27". Strongly recommended.
animatebooleantruePlay the one-shot diagonal reveal on mount; held still under prefers-reduced-motion.
emptyReactNode"No data to display"Shown in place of the cells when data has no rows or no columns.
Data attributeDescription
data-size"sm" or "md".
data-aspect"square" or "wide".
CSS variableDescription
--heatmap-colorThe 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.

PropTypeDefaultDescription
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 attributeDescription
data-rowOn each cell: its row index, from 0.
data-columnOn each cell: its column index, from 0.
data-levelOn each cell with a value: its scale step, 0 (the track) to steps.
data-blankOn each cell with no value.
CSS variableDescription
--heatmap-alphaPer cell: the share of the hue mixed into the muted track, as a percentage.
--heatmap-cell-minThe narrowest a column gets before the grid scrolls (set by size).
--heatmap-rowsRow count, set inline on the layout.
--heatmap-columnsColumn 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.

PropTypeDefaultDescription
tickFormatter(label: string, index: number) => ReactNode-Format a label, or return null to leave its slot empty.
intervalnumber1Show every Nth label only, to thin out a crowded axis.
Data attributeDescription
data-activeOn 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.

PropTypeDefaultDescription
tickFormatter(label: string, index: number) => ReactNode-Format a label, or return null to leave its slot empty.
intervalnumber1Show every Nth label only, to thin out a crowded axis.
Data attributeDescription
data-activeOn the label of the hovered or focused cell's column.
data-spillOn 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.

PropTypeDefaultDescription
lowLabelReactNode"Less"Before the scale; null drops it.
highLabelReactNode"More"After the scale; null drops it.
Data attributeDescription
data-levelOn each swatch: its step, 0 to steps.

FAQ#

One array per row, one entry per column: data={[[4, 0, 12], [7, 3, 9]]}. Pass rows and columns with the labels in the same order. Ragged rows are padded with blanks, and anything that is not a finite number becomes a blank cell.