DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
ChartArea ChartBar ChartLine ChartLive Line ChartComposed ChartPie ChartFunnel ChartRadar ChartRadial ChartSankey ChartScatter ChartCandlestick ChartSunburst ChartChoropleth ChartActivity Heatmap
AI agentsContributing
  1. Components
  2. Activity Heatmap

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/ui

When 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, pair onSelectDate with 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 size over fullWidth: 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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { ActivityHeatmap } from "@tessinaui/ui";
example.tsx
{/* 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 0 is 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.
  • getCellLabel feeds 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, to and today are 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

PropTypeDefaultDescription
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 / tostring—Inclusive range, required for the calendar view.
view"calendar" | "hours""calendar"Contribution calendar or 24×7 weekday-by-hour grid.
levelsnumber5Bucket 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.
weekStart0–61First weekday (0 = Sunday).
monthLabelsbooleantrueMonth labels along the leading edge.
weekdayLabels"none" | "auto" | "all""auto"auto shows alternating weekdays.
hourFormat / hourTickEvery"12h" | "24h" / number"12h" / 3Hours view tick format and density.
showValuesbooleanfalseHours view, md+: print values in cells with per-level ink.
fullWidthbooleanfalseStretch the grid to the container, sizing cells to fit instead of the fixed size px. Cells stay square.
legend / legendEndpointsbooleantrue / falseDiscrete swatch legend; endpoints swap the words for min/max.
locale / labelsstring / Partial<ActivityHeatmapLabels>"en-US"Formatting locale and string overrides.
getCellLabel(date, value | null) => string—Accessible name + tooltip line per cell.
today / selectedDate / onSelectDatestring / string | null / (date) => void—Ring markers and click-to-select. Today's cell also carries aria-current="date".
loading / emptyMessageboolean / ReactNodefalse / —Skeleton state and the message under an all-empty grid.
tooltipbooleantrueCell 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 real gridcell whose aria-label is a full sentence ("12 commits on June 3, 2026") and whose aria-describedby points 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.

Choropleth ChartChatBubble

On this page

When to useWhen not to useInstallationUsageExamplesDefaultHour gridUsage panelDot cellsBinaryScalesSelectionVerticalMonthsSizesLoading skeleton and empty stateOn colorAPI ReferencePropsThemingAccessibilityRight to left