Activity Heatmap
Calendar/contribution heatmap — one cell per day (or per weekday × hour), tinted by value on a preset-derived ramp. The contribution-graph shape done accessibly — native table grid, roving arrow-key walk, per-cell accessible names, an explicit empty state distinct from level 0 — with quantile/quantize/threshold bucketing, an hour-grid view, vertical and month-chunked layouts, and RTL.
pnpm add @tessinaui/uiWhen to use
- A pattern over a long range. Contribution history, usage by day, peak hours by weekday: the rhythm across many days at a glance, not any one day's exact number.
- Streaks and quiet stretches. When gaps matter as much as peaks, a tint shows them without a full time-series chart.
- A weekday-by-hour question.
view="hours"aggregates timestamps into a 24×7 grid for peak-hours panels. - One measure per grid. A tint cannot carry two, so split a second measure into a second grid.
- With the scale in view. Keep the
legend, or state the scale beside the grid: the ramp means nothing without it.
When not to use
- To compare exact values or read a trend. Use BarChart or LineChart: a position on an axis reads more precisely than a tint.
- To choose a date. Use Calendar or
DatePicker. A cell is 8-16px (20px at most
with
fullWidth), under the 24px pointer target of WCAG 2.5.8, because the density is the information. When selection matters, paironSelectDatewith a control sized for a pointer, such as a date picker or a list of the same days. - To look up individual values. Use Table or DataGrid.
- For a short range that can change. Prefer a bigger
sizeoverfullWidth: fixed cells keep the grid's height as columns come and go. - On a phone, for the hour grid. Prefer a single-axis bar chart over shrinking a 24×7 matrix.
Installation
pnpm add @tessinaui/uiUsage
import { ActivityHeatmap } from "@tessinaui/ui";{/* A contribution calendar: caller supplies the range, one datum per day */}
<ActivityHeatmap
data={[{ date: "2026-06-01", value: 12 }, /* … */]}
from="2026-01-05"
to="2026-06-28"
getCellLabel={(date, value) =>
value === null ? `No data on ${date}` : `${value} commits on ${date}`
}
/>
{/* Peak hours: timestamps aggregate into a 24×7 weekday-by-hour grid */}
<ActivityHeatmap
view="hours"
data={[{ date: "2026-06-01T14:00", value: 3 }, /* … */]}
showValues
/>Rules that keep it honest:
- A day with no datum is EMPTY (outlined, no fill) and a recorded
0is level 0 (neutral fill). The two never share a visual. - Buckets default to quantile over the positive values — activity data is
long-tailed. Pass
scale="quantize", explicit{ thresholds }, or a function when the caller already knows its levels. getCellLabelfeeds both the tooltip and the cell's accessible name — write it with a unit ("12 commits on…"), not a bare number.- The grid never reads the clock:
from,toandtodayare explicit, so the server and client always render the same markup.
Examples
Default
Hour grid
The peak-hours panel: days as rows, 24 hour columns, values printed in the
cells from md up, and numeric endpoints on the legend. On phones prefer a
single-axis bar chart over shrinking this grid — no product we surveyed ships
a 24×7 matrix on mobile.
Usage panel
The composition the component was built for — stat tiles, a range switcher and
a caption are ordinary components AROUND the grid, not props on it. The grid
keeps fixed cells rather than fullWidth here: the range switcher changes how
many weeks are drawn, and fluid cells would resize with them — seven days would
paint one fat column and double the panel's height. Fixed cells mean a shorter
range simply ends sooner, at the same cell size and the same height.
Dot cells
Binary
levels={2} collapses the ramp to filled/neutral — the habit-tracker case,
here with a compact four-week window.
Scales
Selection
A ring marks today and the selected day — never a fill swap, which would
corrupt the value encoding.
Vertical
Months
Sizes
Loading skeleton and empty state
loading swaps the grid for ActivityHeatmapSkeleton, drawn from the same
cell, spacing and label geometry, so the page does not move when the data
arrives. Pass from/to while loading to get the real column count; render
ActivityHeatmapSkeleton directly where the heatmap has not mounted yet.
On color
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
data | { date: string; value: number }[] | [] | One datum per day (YYYY-MM-DD), or per hour (YYYY-MM-DDTHH:mm) in the hours view. Duplicate dates sum. Empty or omitted, every cell renders the no-data state. |
from / to | string | — | Inclusive range, required for the calendar view. |
view | "calendar" | "hours" | "calendar" | Contribution calendar or 24×7 weekday-by-hour grid. |
levels | number | 5 | Bucket count including level 0, clamped 2–6. |
scale | "quantile" | "quantize" | { thresholds } | (v) => level | "quantile" | How positive values map to ramp steps. |
size | "xs" | "sm" | "md" | "lg" | "md" | Cell size ladder. |
shape | "square" | "dot" | "square" | Cell shape; dot renders circles. |
rounded | "none" | "sm" | "md" | "lg" | "full" | "sm" | Cell radius ladder (mark shape class). |
orientation | "horizontal" | "vertical" | "horizontal" | Calendar only: vertical runs weeks as rows. |
layout | "flow" | "months" | "flow" | Calendar only: one strip, or per-month mini grids. |
weekStart | 0–6 | 1 | First weekday (0 = Sunday). |
monthLabels | boolean | true | Month labels along the leading edge. |
weekdayLabels | "none" | "auto" | "all" | "auto" | auto shows alternating weekdays. |
hourFormat / hourTickEvery | "12h" | "24h" / number | "12h" / 3 | Hours view tick format and density. |
showValues | boolean | false | Hours view, md+: print values in cells with per-level ink. |
fullWidth | boolean | false | Stretch the grid to the container, sizing cells to fit instead of the fixed size px. Cells stay square. |
legend / legendEndpoints | boolean | true / false | Discrete swatch legend; endpoints swap the words for min/max. |
locale / labels | string / Partial<ActivityHeatmapLabels> | "en-US" | Formatting locale and string overrides. |
getCellLabel | (date, value | null) => string | — | Accessible name + tooltip line per cell. |
today / selectedDate / onSelectDate | string / string | null / (date) => void | — | Ring markers and click-to-select. Today's cell also carries aria-current="date". |
loading / emptyMessage | boolean / ReactNode | false / — | Skeleton state and the message under an all-empty grid. |
tooltip | boolean | true | Cell tooltips (hover + focus; tap holds them open on touch). |
Theming
Cell tints are five literal oklch(from var(--color-chart-1) …) steps — the
same relative-colour idiom as the charts — so the grid recolours with the
theme preset automatically and needs no tokens of its own. Light theme runs
the low levels lighter; dark theme runs them darker; both converge on
--chart-1 at full intensity. Every cell also carries a hairline border, so
the light end of the ramp and the empty state survive greyscale,
prefers-contrast: more and Windows forced-colors without relying on tint.
Accessibility
- Structure: a native
<table role="grid" aria-readonly="true">with an sr-only<caption>— the pattern the reference graph ships, which no charting library matches. Each day is a realgridcellwhosearia-labelis a full sentence ("12 commits on June 3, 2026") and whosearia-describedbypoints at its legend swatch ("Activity level 2 of 4"). The legend markup stays in the tree even when visually hidden so those references never dangle. - Keyboard: one Tab stop; arrows walk cells spatially, Home/End jump within the row, PageUp/PageDown within the column. Edge presses are consumed so the page never scrolls under the grid.
- Today is named, not only drawn: its cell carries
aria-current="date", and its ring clears 3:1 against the page and every coloured plate. - Tooltips open on hover and keyboard focus (house Tooltip); the cell's accessible name carries the same information, so nothing lives only in the tip.
- Touch: cells sit below the 44px floor by design — a year of days
cannot give each one a 44px target, and tap-extension pads are forbidden on
adjacent tiles (overlapping pads misroute taps). The documented pattern: a
tap focuses the cell and holds its tooltip open while focused. A cell is
also under the 24px target of WCAG 2.5.8 at every
size, so a day that must be picked by pointer needs an equivalent control beside the grid (see When to use). - Reduced motion: nothing animates — there is no cell-by-cell entrance, and hover feedback is colour-only, which §4 leaves ungated.
Right to left
dir="rtl" runs weeks right-to-left; month labels, the weekday column, the
legend and the arrow-key walk all follow. Pair it with locale for localized
month/weekday names and digits via Intl.