Heatmap
Values as tinted cells in five accent strengths, as a calendar of weeks or a labelled matrix. Arrow keys move through the cells.
One of 7 components in Charts
Details
What Heatmap brings along, read from the registry and the source.
- Category
- Charts
- Area
- Elements
- Variants
- 2
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/heatmap.tsx
- Brings along
- utilskitchart
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The HeatmapProps interface from components/ui/heatmap.tsx, with the comments from the code. Also takes every prop of ChartA11y.
variantoptional'calendar' | 'matrix'calendar: weeks by weekdays from dated values. matrix: any rows by columns.
daysoptionalHeatmapDay[]calendar: one entry per day. Missing days count as empty.
weekStartoptional0 | 1calendar: first day of the week, 1 Monday, 0 Sunday. Default: Monday in German, Sunday in English.
rowsoptionalstring[]matrix: row labels, e.g. weekdays
columnsoptionalstring[]matrix: column labels, e.g. hours
valuesoptionalnumber[][]matrix: values[row][column]
formatoptionalIntl.NumberFormatOptionsValue format in tooltip and table
maxoptionalnumberUpper end of the scale. Default: the largest value.
rowLabeloptionalstringHeader of the first table column (matrix), e.g. "Day"
legendoptionalbooleanScale from less to more under the grid
Accessibility
Read from the source of the component.
- Heatmap handles the keys Escape, Home and End itself.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 280 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartRoot, ChartSurface, ChartTable, ChartTooltip, numberFormatter, parseDate, rampColor, textWidth, useEntrance, useIntlLocale, useWidth, type ChartA11y } from '@/components/ui/chart';
export interface HeatmapDay {
/** "2026-09-14" */
date: string;
value: number;
}
export interface HeatmapProps extends ChartA11y {
/** calendar: weeks by weekdays from dated values. matrix: any rows by columns. */
variant?: 'calendar' | 'matrix';
/** calendar: one entry per day. Missing days count as empty. */
days?: HeatmapDay[];
/** calendar: first day of the week, 1 Monday, 0 Sunday. Default: Monday in German, Sunday in English. */
weekStart?: 0 | 1;
/** matrix: row labels, e.g. weekdays */
rows?: string[];
/** matrix: column labels, e.g. hours */
columns?: string[];
/** matrix: values[row][column] */
values?: number[][];
/** Value format in tooltip and table */
format?: Intl.NumberFormatOptions;
/** Upper end of the scale. Default: the largest value. */
max?: number;
/** Header of the first table column (matrix), e.g. "Day" */
rowLabel?: string;
/** Scale from less to more under the grid */
legend?: boolean;
className?: string;
}
const GAP = 3;
/**
* Values as a grid of tinted cells: a calendar of weeks, or a labelled matrix.
* <Heatmap variant="calendar" label="Bookings per day" days={[{ date: '2026-09-14', value: 6 }, …]} />
* <Heatmap variant="matrix" label="Guests by hour" rows={['Mon', …]} columns={['8', …]} values={[[…], …]} />
*/
export function Heatmap({ variant = 'calendar', days = [], weekStart, rows = [], columns = [], values = [], format, max, rowLabel, legend = true, label, summary, labels, animate = true, className }: HeatmapProps) {
const intl = useIntlLocale();
const t = useTexts(CHART_TEXTS, labels);
const fmt = numberFormatter(intl, format);
const [wrapRef, width] = useWidth<HTMLDivElement>(640);
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const [active, setActive] = useState<{ r: number; c: number } | null>(null);
// The ring and the tooltip keep the last cell while they fade, and glide from cell to cell
const lastRef = useRef<{ r: number; c: number } | null>(null);
const wasOpen = useRef(false);
if (active) lastRef.current = active;
const jump = !wasOpen.current;
useEffect(() => {
wasOpen.current = active !== null;
});
// Build one grid for both variants: cells[r][c] with a value (or null), a title and a column label
type Cell = { v: number | null; title: string } | null;
let grid: Cell[][] = [];
let rowNames: string[] = [];
let colNames: string[] = [];
let colMarks: (string | null)[] = [];
let tableHead: string[] = [];
let tableRows: string[][] = [];
if (variant === 'calendar') {
const start = weekStart ?? (intl === 'de-DE' ? 1 : 0);
const byDate = new Map(days.map((d) => [d.date, d.value]));
const dates = days.map((d) => parseDate(d.date)).filter((d): d is Date => !!d).sort((a, b) => a.getTime() - b.getTime());
const first = dates[0] ?? new Date(Date.UTC(2026, 0, 1));
const last = dates[dates.length - 1] ?? first;
const offset = (first.getUTCDay() - start + 7) % 7;
const origin = new Date(first.getTime() - offset * 864e5);
const weeks = Math.max(1, Math.ceil(((last.getTime() - origin.getTime()) / 864e5 + 1) / 7));
const long = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', weekday: 'short', day: 'numeric', month: 'long' });
const monthF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', month: 'short' });
const dayF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', weekday: 'short' });
// Label Monday, Wednesday and Friday, whatever day the week starts with
rowNames = Array.from({ length: 7 }, (_, r) => ([1, 3, 5].includes((r + start) % 7) ? dayF.format(new Date(origin.getTime() + r * 864e5)) : ''));
grid = Array.from({ length: 7 }, (_, r) =>
Array.from({ length: weeks }, (_, c) => {
const d = new Date(origin.getTime() + (c * 7 + r) * 864e5);
if (d < first || d > last) return null;
const key = d.toISOString().slice(0, 10);
return { v: byDate.get(key) ?? null, title: long.format(d) };
}),
);
colMarks = Array.from({ length: weeks }, (_, c) => {
const d = new Date(origin.getTime() + c * 7 * 864e5);
const top = new Date(Math.max(d.getTime(), first.getTime()));
return c === 0 || top.getUTCDate() <= 7 ? monthF.format(top) : null;
});
colNames = colMarks.map((m) => m ?? '');
tableHead = ['', label];
tableRows = days.map((d) => [long.format(parseDate(d.date)!), fmt(d.value)]);
} else {
rowNames = rows;
colNames = columns;
colMarks = columns;
grid = rows.map((_, r) => columns.map((col, c) => ({ v: values[r]?.[c] ?? null, title: `${rows[r]}, ${col}` })));
tableHead = [rowLabel ?? '', ...columns];
tableRows = rows.map((row, r) => [row, ...columns.map((_, c) => (typeof values[r]?.[c] === 'number' ? fmt(values[r][c]) : t.noData))]);
}
const nRows = grid.length;
const nCols = grid[0]?.length ?? 0;
const all = grid.flat().map((c) => c?.v ?? 0);
const top = max ?? Math.max(1, ...all);
const step = (v: number | null) => (v === null || v <= 0 ? -1 : Math.min(4, Math.max(0, Math.ceil((v / top) * 5) - 1)));
// Layout
const left = Math.max(...rowNames.map((s) => textWidth(s, 11)), 0) + (rowNames.some(Boolean) ? 10 : 0);
const headH = 20;
let cellW: number;
let cellH: number;
let firstCol = 0;
if (variant === 'calendar') {
const fit = Math.floor((width - left + GAP) / Math.max(1, nCols)) - GAP;
cellW = Math.min(26, fit);
// Too narrow for every week: keep the most recent weeks that fit at 10 px
if (cellW < 10) {
cellW = 10;
firstCol = Math.max(0, nCols - Math.floor((width - left + GAP) / (cellW + GAP)));
}
cellH = cellW;
} else {
cellW = (width - left + GAP) / Math.max(1, nCols) - GAP;
cellH = Math.max(18, Math.min(28, round(cellW * 0.7, 0)));
}
const visibleCols = nCols - firstCol;
const height = headH + nRows * (cellH + GAP) - GAP;
const cx = (c: number) => round(left + (c - firstCol) * (cellW + GAP));
const cy = (r: number) => round(headH + r * (cellH + GAP));
const markEvery = variant === 'matrix' ? Math.max(1, Math.ceil((Math.max(...colNames.map((s) => textWidth(s, 11)), 1) + 6) / (cellW + GAP))) : 1;
// Cells enter as a diagonal wave from the top left corner (kitch-cell below)
// Cells in reading order of time: column by column (weeks), then row by row
const ordered = () => {
const out: { r: number; c: number }[] = [];
for (let c = firstCol; c < nCols; c++) for (let r = 0; r < nRows; r++) if (grid[r][c]) out.push({ r, c });
return out;
};
const valid = (r: number, c: number) => r >= 0 && r < nRows && c >= firstCol && c < nCols && grid[r][c] !== null;
const onKeyDown = (e: React.KeyboardEvent) => {
const cur = active ?? { r: 0, c: firstCol };
const moves: Record<string, [number, number]> = { ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0] };
if (e.key === 'Escape') return setActive(null);
if (e.key === 'Home' || e.key === 'End') {
e.preventDefault();
const flat = ordered();
setActive(e.key === 'Home' ? flat[0] : flat[flat.length - 1]);
return;
}
const m = moves[e.key];
if (!m) return;
e.preventDefault();
if (!active) {
const firstValid = ordered()[0];
return setActive(firstValid ?? null);
}
let r = cur.r + m[0];
let c = cur.c + m[1];
// Skip the gaps at the start and end of a calendar
while (r >= 0 && r < nRows && c >= firstCol && c < nCols && !valid(r, c)) {
r += m[0];
c += m[1];
}
if (valid(r, c)) setActive({ r, c });
};
const pick = (e: React.PointerEvent<HTMLDivElement>) => {
const box = e.currentTarget.getBoundingClientRect();
const px = ((e.clientX - box.left) / (box.width || width)) * width;
const py = e.clientY - box.top;
const c = Math.floor((px - left) / (cellW + GAP)) + firstCol;
const r = Math.floor((py - headH) / (cellH + GAP));
setActive(valid(r, c) ? { r, c } : null);
};
const act = active ? grid[active.r][active.c] : null;
const shown = lastRef.current && grid[lastRef.current.r]?.[lastRef.current.c] && lastRef.current.c >= firstCol ? lastRef.current : null;
const shownCell = shown ? grid[shown.r][shown.c] : null;
const live = act ? `${act.title}, ${act.v === null ? t.noData : fmt(act.v)}` : '';
const total = all.reduce((a, b) => a + b, 0);
const peak = grid.flatMap((row) => row).reduce<Cell>((best, cell) => (cell && cell.v !== null && (!best || (best.v ?? -1) < cell.v) ? cell : best), null);
const auto = peak ? `${t.total} ${fmt(total)}, max ${fmt(peak.v ?? 0)} (${peak.title})` : '';
return (
<ChartRoot anim={anim} rootRef={rootRef} className={className}>
<div ref={wrapRef}>
<ChartSurface
label={label}
summary={summary ?? auto}
labels={labels}
live={live}
table={<ChartTable caption={label} head={tableHead} rows={tableRows} />}
onKeyDown={onKeyDown}
onFocus={(e) => {
if (e.target === e.currentTarget && !active && e.currentTarget.matches(':focus-visible')) {
const firstValid = ordered()[0];
setActive(firstValid ?? null);
}
}}
onBlur={() => setActive(null)}
onPointerMove={pick}
onPointerDown={pick}
onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
>
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="xMidYMid meet" aria-hidden className="block overflow-visible">
<g className="text-[11px]" fill="var(--muted-foreground)">
{rowNames.map((s, r) =>
s ? (
<text key={r} x={left - 10} y={cy(r) + cellH / 2} dy="0.34em" textAnchor="end" className={cn(active?.r === r && 'fill-foreground')}>
{s}
</text>
) : null,
)}
{colMarks.map((s, c) =>
s && c >= firstCol && (variant === 'calendar' || (c - firstCol) % markEvery === 0) && !(variant === 'calendar' && c > firstCol && colMarks.slice(Math.max(firstCol, c - 2), c).some(Boolean)) ? (
<text key={c} x={variant === 'calendar' ? cx(c) : round(cx(c) + cellW / 2)} y={12} textAnchor={variant === 'calendar' ? 'start' : 'middle'} className={cn(active?.c === c && 'fill-foreground')}>
{s}
</text>
) : null,
)}
</g>
{grid.map((row, r) =>
row.map((cell, c) => {
if (!cell || c < firstCol) return null;
const s = step(cell.v);
return (
<rect
key={`${r}-${c}`}
x={cx(c)}
y={cy(r)}
width={round(cellW)}
height={round(cellH)}
rx={variant === 'calendar' ? 3 : 4}
fill={s < 0 ? 'var(--kitch-empty)' : rampColor(s)}
className={anim ? 'kitch-cell' : undefined}
style={anim ? ({ '--kitch-d': `${round(((c - firstCol + r) / Math.max(1, visibleCols + nRows - 2)) * 640, 0)}ms` } as React.CSSProperties) : undefined}
/>
);
}),
)}
{shown && (
<rect
x={-0.75}
y={-0.75}
width={round(cellW + 1.5)}
height={round(cellH + 1.5)}
rx={variant === 'calendar' ? 3.5 : 4.5}
fill="none"
stroke="var(--foreground)"
strokeWidth={1.5}
className="kitch-glide pointer-events-none"
data-jump={jump ? '' : undefined}
style={{ transform: `translate(${cx(shown.c)}px, ${cy(shown.r)}px)`, opacity: active ? 1 : 0 }}
/>
)}
</svg>
{shown && shownCell && (
<ChartTooltip open={active !== null} jump={jump} x={round(cx(shown.c) + cellW / 2)} y={Math.max(0, Math.min(cy(shown.r) - 6, height - 56))} width={width} offset={cellW / 2 + 8} title={shownCell.title} rows={[{ key: 'v', label, value: shownCell.v === null ? t.noData : fmt(shownCell.v) }]} />
)}
</ChartSurface>
</div>
{legend && (
<div className="mt-4 flex items-center justify-end gap-1.5 text-[11px] text-muted-foreground" aria-hidden>
<span className="me-1">{t.less}</span>
<span className="size-3 rounded-[3px]" style={{ background: 'var(--kitch-empty)' }} />
{[0, 1, 2, 3, 4].map((s) => (
<span key={s} className="size-3 rounded-[3px]" style={{ background: rampColor(s) }} />
))}
<span className="ms-1">{t.more}</span>
</div>
)}
</ChartRoot>
);
}Frequent questions
How do I install Heatmap?
With aiw add @ai-website/heatmap. The command puts components/ui/heatmap.tsx into your project, the code is yours from then on. It brings along chart. It needs no further npm packages.
Which variants does Heatmap have?
2 variants, Calendar and Matrix. You pick one with the variant prop, for example variant="matrix".
Can an AI assistant build with Heatmap?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Heatmap with aiw_components and install it with aiw_add @ai-website/heatmap. Name the variant in your prompt, for example variant="calendar", and the assistant writes your content straight into the props.