Calendar heatmap
Days as tinted cells in a full calendar. A year with its key and four numbers, month calendars with day numbers, several years stacked and the longest streaks to pick. Arrow keys walk the days, phones scroll the weeks.
Every day of the year in one picture
A calendar heatmap tints every day by its value, so habits, seasons and busy weeks show up at a glance. It goes beyond the half year calendar mode of the heatmap, the arrow keys walk the days and phones scroll the weeks.
Year shows a whole year in weeks with the key and its thresholds and four numbers above, days still to come stay as outlines. Month calendars draws one small calendar per month with the day numbers and the month total, read like a wall calendar. Several years stacks three years on the same weeks with the total of each year at the end, so seasons and growth line up. Streaks lets days that reach the goal form streaks, the longest three are toggles and a picked one is outlined while the rest steps back.
Choose thresholds that split your data into even groups, then the colors carry information. Show four or five steps, more tints are hard to tell apart. Put the key right next to the chart, a heatmap without a key is only a pattern.
Variants
Details
What Calendar heatmap brings along, read from the registry and the source.
- Category
- Charts
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/calendar-heatmap.tsx
- Brings along
- utilskitchartchart-figure
- npm packages
- None
- Languages
- English and German, 14 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CalendarHeatmapProps interface from components/ui/calendar-heatmap.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'label'>, ChartFigureProps.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalCalendarHeatmapVariantyearmonthsyearsstreaks
daysrequiredCalendarDay[]One entry per day. Missing days count as zero.
fromoptionalstringFirst and last day shown. Default: the whole years (year, years, streaks) or months (months) of the data.
tooptionalstringweekStartoptional0 | 1First day of the week, 1 Monday, 0 Sunday. Default: Monday in German, Sunday in English.
valueLabelrequiredstringName of the value in tooltip and table, e.g. "Workouts"
formatoptionalIntl.NumberFormatOptionslevelsoptionalnumber[]Lower bounds of the five strengths, smallest first. Default: from the largest value.
goaloptionalnumberstreaks: a day counts as active from this value on. Default 1.
statsoptionalbooleanyear, streaks: the four numbers above the grid. Default true.
Built-in texts
Calendar heatmap writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 14 texts
| Key | English | German |
|---|---|---|
| total | English Total | German Gesamt |
| active | English Active days | German Aktive Tage |
| best | English Best day | German Bester Tag |
| longest | English Longest streak | German Längste Serie |
| current | English Current streak | German Aktuelle Serie |
| days | English {n} days | German {n} Tage |
| day | English 1 day | German 1 Tag |
| span | English {a} to {b} | German {a} bis {b} |
| streak | English Streak | German Serie |
| streaks | English Longest streaks | German Längste Serien |
| none | English No streak yet | German Noch keine Serie |
| date | English Date | German Datum |
| summary | English {total} on {active} days, best day {best}. | German {total} an {active} Tagen, bester Tag {best}. |
| streakOf | English Part of a streak of {n} days | German Teil einer Serie von {n} Tagen |
Accessibility
Read from the source of the component.
- Calendar heatmap handles the keys Escape, Home, End and Space itself.
- Sets aria-pressed, so screen readers announce purpose and state.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
- 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, 514 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartRoot, ChartSurface, ChartTable, ChartTooltip, fill, numberFormatter, parseDate, rampColor, textWidth, useEntrance, useIntlLocale, useShown, useWidth, type ChartA11y } from '@/components/ui/chart';
import { ChartFigure, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* Days as tinted cells, read as a calendar.
* year a whole year in weeks, months on top, the key with its
* thresholds and four numbers above (total, active days,
* best day, longest streak)
* months one small month calendar per month with the day numbers,
* the way a wall calendar reads
* years several years stacked, one row each, its total at the end,
* so seasons and growth compare at a glance
* streaks runs of active days, the longest three as toggles; a picked
* run is outlined and everything else steps back
*
* On phones the year scrolls sideways (no scrollbar, a fade where more
* follows), starting at the latest week, weekdays stay in place. Arrow keys
* walk the days: left and right a week (a day in the month view), up and
* down a day (a week), Home and End jump to the first and last day.
* heatmap has a half-year calendar mode, this one is the full calendar.
*/
export type CalendarHeatmapVariant = 'year' | 'months' | 'years' | 'streaks';
export interface CalendarDay {
/** "2026-09-14" */
date: string;
value: number;
}
export interface CalendarHeatmapProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: CalendarHeatmapVariant;
/** One entry per day. Missing days count as zero. */
days: CalendarDay[];
/** First and last day shown. Default: the whole years (year, years, streaks) or months (months) of the data. */
from?: string;
to?: string;
/** First day of the week, 1 Monday, 0 Sunday. Default: Monday in German, Sunday in English. */
weekStart?: 0 | 1;
/** Name of the value in tooltip and table, e.g. "Workouts" */
valueLabel: string;
format?: Intl.NumberFormatOptions;
/** Lower bounds of the five strengths, smallest first. Default: from the largest value. */
levels?: number[];
/** streaks: a day counts as active from this value on. Default 1. */
goal?: number;
/** year, streaks: the four numbers above the grid. Default true. */
stats?: boolean;
className?: string;
}
const TEXTS = {
en: { total: 'Total', active: 'Active days', best: 'Best day', longest: 'Longest streak', current: 'Current streak', days: '{n} days', day: '1 day', span: '{a} to {b}', streak: 'Streak', streaks: 'Longest streaks', none: 'No streak yet', date: 'Date', summary: '{total} on {active} days, best day {best}.', streakOf: 'Part of a streak of {n} days' },
de: { total: 'Gesamt', active: 'Aktive Tage', best: 'Bester Tag', longest: 'Längste Serie', current: 'Aktuelle Serie', days: '{n} Tage', day: '1 Tag', span: '{a} bis {b}', streak: 'Serie', streaks: 'Längste Serien', none: 'Noch keine Serie', date: 'Datum', summary: '{total} an {active} Tagen, bester Tag {best}.', streakOf: 'Teil einer Serie von {n} Tagen' },
};
const DAY = 864e5;
const GAP = 3;
const iso = (t: number) => new Date(t).toISOString().slice(0, 10);
/**
* A calendar heatmap: one year, month calendars, several years or streaks.
* <CalendarHeatmap variant="year" valueLabel="Workouts" days={[{ date: '2026-03-14', value: 2 }, …]} />
*/
export function CalendarHeatmap(props: CalendarHeatmapProps) {
const { variant = 'year', days, from, to, weekStart, valueLabel, format, levels, goal = 1, stats = true, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
const t = useTexts(TEXTS, undefined);
const ct = useTexts(CHART_TEXTS, labels);
const intl = useIntlLocale();
const fmt = numberFormatter(intl, format);
const [wrapRef, width] = useWidth<HTMLDivElement>(760);
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const [active, setActive] = useState<number | null>(null);
const { shown, open, jump } = useShown(active);
const scrollRef = useRef<HTMLDivElement>(null);
const [scroll, setScroll] = useState({ left: 0, max: 0 });
const phone = width < 560;
const name = label ?? title ?? valueLabel;
const start = weekStart ?? (intl === 'de-DE' ? 1 : 0);
const delay = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
const long = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', weekday: 'short', day: 'numeric', month: 'long', year: 'numeric' });
const short = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', day: 'numeric', month: 'short' });
const monthF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', month: 'short' });
const monthLong = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', month: 'long' });
const wdF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', weekday: 'narrow' });
const wdShort = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', weekday: 'short' });
// The range: whole years or whole months of the data
const times = days.map((d) => parseDate(d.date)?.getTime()).filter((x): x is number => x !== undefined).sort((a, b) => a - b);
const dataLo = times[0] ?? Date.UTC(2026, 0, 1);
const dataHi = times[times.length - 1] ?? dataLo;
const lo0 = new Date(dataLo);
const hi0 = new Date(dataHi);
const lo = from ? parseDate(from)!.getTime() : variant === 'months' ? Date.UTC(lo0.getUTCFullYear(), lo0.getUTCMonth(), 1) : Date.UTC(lo0.getUTCFullYear(), 0, 1);
const hi = to ? parseDate(to)!.getTime() : variant === 'months' ? Date.UTC(hi0.getUTCFullYear(), hi0.getUTCMonth() + 1, 0) : Date.UTC(hi0.getUTCFullYear(), 11, 31);
const count = Math.max(1, Math.round((hi - lo) / DAY) + 1);
const byDate = new Map(days.map((d) => [d.date, d.value]));
const vals = Array.from({ length: count }, (_, i) => byDate.get(iso(lo + i * DAY)) ?? 0);
const dateOf = (i: number) => new Date(lo + i * DAY);
// Days after the last data point are future days: drawn as empty outlines, not as zero
const future = (i: number) => lo + i * DAY > dataHi;
// Strengths
const vMax = Math.max(0, ...vals);
const th = levels?.length ? levels.slice(0, 5) : [1, Math.max(2, Math.ceil(vMax * 0.25)), Math.max(3, Math.ceil(vMax * 0.45)), Math.max(4, Math.ceil(vMax * 0.65)), Math.max(5, Math.ceil(vMax * 0.85))].filter((v, i, a) => i === 0 || v > a[i - 1]);
const step = (v: number) => {
let s = -1;
th.forEach((x, k) => v >= x && (s = k));
return s < 0 ? -1 : Math.round((s / Math.max(1, th.length - 1)) * 4);
};
// Stats and streaks
const total = vals.reduce((a, b) => a + b, 0);
const activeDays = vals.filter((v) => v >= goal).length;
const bestI = vals.reduce((b, v, i) => (v > vals[b] ? i : b), 0);
const runs: { a: number; b: number }[] = [];
for (let i = 0, s = -1; i <= count; i++) {
const on = i < count && vals[i] >= goal && !future(i);
if (on && s < 0) s = i;
if (!on && s >= 0) {
runs.push({ a: s, b: i - 1 });
s = -1;
}
}
const longest = [...runs].sort((x, y) => y.b - y.a - (x.b - x.a) || x.a - y.a).slice(0, 3);
const lastDay = Math.min(count - 1, Math.round((dataHi - lo) / DAY));
const currentRun = runs.find((r) => r.b === lastDay || r.b === lastDay - 1);
const [picked, setPicked] = useState<number | null>(variant === 'streaks' && longest.length ? 0 : null);
const pickedRun = picked !== null ? longest[picked] : null;
const runOf = (i: number) => runs.find((r) => i >= r.a && i <= r.b);
const lenText = (r: { a: number; b: number }) => (r.b - r.a + 1 === 1 ? t.day : fill(t.days, { n: String(r.b - r.a + 1) }));
// Week grid: column = week, row = weekday
const offset = (dateOf(0).getUTCDay() - start + 7) % 7;
const weeks = Math.ceil((count + offset) / 7);
const colOf = (i: number) => Math.floor((i + offset) / 7);
const rowOf = (i: number) => (i + offset) % 7;
// Keyboard: in the week grid left and right is a week, in month calendars a day
const onKeyDown = (e: React.KeyboardEvent) => {
const cur = active ?? Math.min(lastDay, count - 1);
const months = variant === 'months';
const moves: Record<string, number> = months ? { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7, PageUp: -30, PageDown: 30 } : { ArrowLeft: -7, ArrowRight: 7, ArrowUp: -1, ArrowDown: 1, PageUp: -30, PageDown: 30 };
if (e.key === 'Escape') return setActive(null);
let next: number;
if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = lastDay;
else if (moves[e.key] !== undefined) next = active === null ? cur : cur + moves[e.key];
else return;
e.preventDefault();
setActive(Math.max(0, Math.min(count - 1, next)));
};
// Where the weeks do not fit (phones, several years on tablets) they scroll: start at the
// latest week and keep the active day in view. The refs carry the geometry of the render.
const cRef = useRef(12);
const posRef = useRef<(i: number) => { x: number; y: number }>(() => ({ x: 0, y: 0 }));
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollLeft = el.scrollWidth;
setScroll({ left: el.scrollLeft, max: el.scrollWidth - el.clientWidth });
}, [phone, variant, width]);
useEffect(() => {
const el = scrollRef.current;
if (!el || active === null || variant === 'months' || el.scrollWidth <= el.clientWidth) return;
const cell = cRef.current;
const x = posRef.current(active).x;
if (x < el.scrollLeft + 8) el.scrollLeft = x - 24;
else if (x + cell > el.scrollLeft + el.clientWidth - 8) el.scrollLeft = x + cell - el.clientWidth + 24;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
const live = active !== null ? `${long.format(dateOf(active))}, ${future(active) ? ct.noData : fmt(vals[active])}${runOf(active) && variant === 'streaks' ? `. ${fill(t.streakOf, { n: String(runOf(active)!.b - runOf(active)!.a + 1) })}` : ''}` : '';
const auto = fill(t.summary, { total: fmt(total), active: String(activeDays), best: `${short.format(dateOf(bestI))} (${fmt(vals[bestI])})` });
const table = <ChartTable caption={name} head={[t.date, valueLabel]} rows={vals.map((v, i) => (future(i) ? null : [long.format(dateOf(i)), fmt(v)])).filter((r): r is string[] => r !== null && (r[1] !== fmt(0) || variant === 'months'))} />;
const fillOf = (i: number) => {
if (future(i)) return 'transparent';
const s = step(vals[i]);
return s < 0 ? 'var(--kitch-empty)' : rampColor(s);
};
const dimOf = (i: number) => (pickedRun && (i < pickedRun.a || i > pickedRun.b) ? 0.28 : 1);
/* The key with thresholds */
const key = (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[11px] text-muted-foreground" aria-hidden>
<span className="me-1">{ct.less}</span>
<span className="size-3 rounded-[3px]" style={{ background: 'var(--kitch-empty)' }} />
{th.map((x, k) => (
<span key={k} className="inline-flex items-center gap-1">
<span className="size-3 rounded-[3px]" style={{ background: rampColor(Math.round((k / Math.max(1, th.length - 1)) * 4)) }} />
<span className="tabular-nums">{`${fmt(x)}+`}</span>
</span>
))}
<span className="ms-1">{ct.more}</span>
</div>
);
const statRow =
stats && (variant === 'year' || variant === 'streaks') ? (
<dl className="mb-5 grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-4">
{[
{ k: t.total, v: fmt(total) },
{ k: t.active, v: new Intl.NumberFormat(intl).format(activeDays) },
{ k: t.best, v: `${fmt(vals[bestI])}`, note: short.format(dateOf(bestI)) },
variant === 'streaks' ? { k: t.current, v: currentRun ? lenText(currentRun) : t.none } : { k: t.longest, v: longest[0] ? lenText(longest[0]) : t.none },
].map((s, k) => (
<div key={s.k} className={cn('bg-card px-3.5 py-3', anim && 'kitch-rise')} style={delay(80 + k * 60)}>
<dt className="text-[12px] text-muted-foreground">{s.k}</dt>
<dd className="mt-1 flex flex-wrap items-baseline gap-x-1.5 text-[17px] font-semibold tabular-nums text-foreground">
{s.v}
{s.note && <span className="text-[12px] font-normal text-muted-foreground">{s.note}</span>}
</dd>
</div>
))}
</dl>
) : null;
const surfaceBase = {
label: name,
summary: summary ?? auto,
labels,
live,
table,
onKeyDown,
onFocus: (e: React.FocusEvent) => e.target === e.currentTarget && active === null && (e.currentTarget as HTMLElement).matches(':focus-visible') && setActive(lastDay),
onBlur: () => setActive(null),
onPointerLeave: (e: React.PointerEvent) => e.pointerType === 'mouse' && setActive(null),
};
/* ---------------- months: small calendars ---------------- */
if (variant === 'months') {
const monthsList: { y: number; m: number }[] = [];
for (let d = new Date(lo); d.getTime() <= hi; d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1))) monthsList.push({ y: d.getUTCFullYear(), m: d.getUTCMonth() });
const perRow = width >= 900 ? 4 : width >= 600 ? 3 : 2;
const colW = (width - (perRow - 1) * 20) / perRow;
const c = Math.floor((colW - 6 * GAP) / 7);
const showNum = c >= 18;
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
<div ref={wrapRef}>
<ChartSurface {...surfaceBase}>
<div className="grid gap-x-5 gap-y-6" style={{ gridTemplateColumns: `repeat(${perRow}, minmax(0, 1fr))` }}>
{monthsList.map(({ y, m }, mi) => {
const first = Date.UTC(y, m, 1);
const nDays = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
const lead = (new Date(first).getUTCDay() - start + 7) % 7;
const sum = Array.from({ length: nDays }, (_, d) => {
const i = Math.round((first + d * DAY - lo) / DAY);
return i >= 0 && i < count ? vals[i] : 0;
}).reduce((a, b) => a + b, 0);
const rowsN = Math.ceil((lead + nDays) / 7);
return (
<section key={`${y}-${m}`} className={cn(anim && 'kitch-rise')} style={delay(60 + mi * 45)}>
<h4 className="mb-2 flex items-baseline justify-between gap-2 text-[13px] font-medium text-foreground">
<span className="truncate">{monthLong.format(new Date(first))}</span>
<span className="text-[12px] font-normal tabular-nums text-muted-foreground">{fmt(sum)}</span>
</h4>
<svg width="100%" height={14 + rowsN * (c + GAP)} viewBox={`0 0 ${round(7 * (c + GAP) - GAP)} ${14 + rowsN * (c + GAP)}`} preserveAspectRatio="xMinYMin meet" aria-hidden className="block overflow-visible">
{Array.from({ length: 7 }, (_, r) => (
<text key={r} x={round(r * (c + GAP) + c / 2)} y={9} textAnchor="middle" className="text-[10px]" fill="var(--muted-foreground)">
{wdF.format(new Date(Date.UTC(2026, 0, 4 + ((r + start) % 7))))}
</text>
))}
{Array.from({ length: nDays }, (_, d) => {
const i = Math.round((first + d * DAY - lo) / DAY);
if (i < 0 || i >= count) return null;
const col = (lead + d) % 7;
const row = Math.floor((lead + d) / 7);
const x = round(col * (c + GAP));
const yy = round(14 + row * (c + GAP));
const s = future(i) ? -2 : step(vals[i]);
const ink = s >= 3 ? 'var(--primary-foreground)' : s === 2 ? 'var(--foreground)' : 'var(--muted-foreground)';
return (
<g key={d} onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)} onPointerDown={() => setActive(i)} className="cursor-pointer">
<rect x={x} y={yy} width={c} height={c} rx={Math.min(5, c / 4)} fill={fillOf(i)} stroke={future(i) ? 'var(--kitch-grid)' : 'none'} strokeDasharray={future(i) ? '2 2' : undefined} className={anim ? 'kitch-cell' : undefined} style={delay(120 + mi * 45 + d * 6)} />
{showNum && (
<text x={round(x + c / 2)} y={round(yy + c / 2)} dy="0.34em" textAnchor="middle" className="pointer-events-none text-[10px] tabular-nums" fill={ink}>
{d + 1}
</text>
)}
{active === i && <rect x={x - 1.5} y={yy - 1.5} width={c + 3} height={c + 3} rx={Math.min(6, c / 4 + 1)} fill="none" stroke="var(--foreground)" strokeWidth="1.5" />}
</g>
);
})}
</svg>
</section>
);
})}
</div>
{shown !== null && (
<p aria-hidden className={cn('mt-4 flex min-h-5 flex-wrap items-center gap-x-2 text-[12px] transition-opacity duration-200', open ? 'opacity-100' : 'opacity-0')}>
<span className="text-muted-foreground">{long.format(dateOf(shown))}</span>
<span className="font-semibold tabular-nums text-foreground">{future(shown) ? ct.noData : `${fmt(vals[shown])} ${valueLabel}`}</span>
</p>
)}
</ChartSurface>
</div>
<div className="mt-4 flex justify-end">{key}</div>
</ChartRoot>
</ChartFigure>
);
}
/* ---------------- year, years, streaks: the week grid ---------------- */
const yearsList = variant === 'years' ? [...new Set(Array.from({ length: count }, (_, i) => dateOf(i).getUTCFullYear()))] : [];
const leftW = textWidth(wdShort.format(new Date(Date.UTC(2026, 0, 7))), 11) + 10;
const yearW = variant === 'years' ? 40 : 0;
const totalW = variant === 'years' && !phone ? 64 : 0;
// Years: each row is one calendar year, the same 53 columns
const yearWeeks = 54;
const gridCols = variant === 'years' ? yearWeeks : weeks;
const fitCell = Math.floor((width - leftW - yearW - totalW + GAP) / gridCols) - GAP;
const c = phone ? 12 : Math.max(8, Math.min(variant === 'years' ? 14 : 18, fitCell));
cRef.current = c;
const gridW = gridCols * (c + GAP) - GAP;
const headH = 18;
const blockH = 7 * (c + GAP) - GAP;
const yearGap = 18;
const gridH = variant === 'years' ? headH + yearsList.length * (blockH + yearGap) - yearGap : headH + blockH + (variant === 'streaks' ? 26 : 0);
// Cell position
const yearRow = (i: number) => (variant === 'years' ? yearsList.indexOf(dateOf(i).getUTCFullYear()) : 0);
const posOf = (i: number) => {
if (variant === 'years') {
const d = dateOf(i);
const y0 = Date.UTC(d.getUTCFullYear(), 0, 1);
const off = (new Date(y0).getUTCDay() - start + 7) % 7;
const k = Math.round((d.getTime() - y0) / DAY);
return { x: Math.floor((k + off) / 7) * (c + GAP), y: headH + yearRow(i) * (blockH + yearGap) + ((k + off) % 7) * (c + GAP) };
}
return { x: colOf(i) * (c + GAP), y: headH + rowOf(i) * (c + GAP) };
};
posRef.current = posOf;
// Month marks along the top
const marks: { x: number; text: string }[] = [];
for (let d = new Date(lo); d.getTime() <= hi; d = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1))) {
if (variant === 'years' && d.getUTCFullYear() !== yearsList[0]) break;
const i = Math.round((d.getTime() - lo) / DAY);
const p = posOf(Math.max(0, i));
const x = rowOf(Math.max(0, i)) === 0 || variant === 'years' ? p.x : p.x + c + GAP;
if (!marks.length || x - marks[marks.length - 1].x >= textWidth(marks[marks.length - 1].text, 11) + 6) marks.push({ x, text: monthF.format(d) });
}
const pick = (e: React.PointerEvent<HTMLDivElement>) => {
const b = e.currentTarget.getBoundingClientRect();
const px = e.clientX - b.left;
const py = e.clientY - b.top;
const col = Math.floor(px / (c + GAP));
if (variant === 'years') {
const yr = Math.floor((py - headH) / (blockH + yearGap));
const row = Math.floor((py - headH - yr * (blockH + yearGap)) / (c + GAP));
const year = yearsList[yr];
if (year === undefined || row < 0 || row > 6) return setActive(null);
const y0 = Date.UTC(year, 0, 1);
const off = (new Date(y0).getUTCDay() - start + 7) % 7;
const i = Math.round((y0 + (col * 7 + row - off) * DAY - lo) / DAY);
return setActive(i >= 0 && i < count && dateOf(i).getUTCFullYear() === year ? i : null);
}
const row = Math.floor((py - headH) / (c + GAP));
const i = col * 7 + row - offset;
setActive(row >= 0 && row < 7 && i >= 0 && i < count ? i : null);
};
const sp = shown !== null ? posOf(shown) : { x: 0, y: 0 };
const tipX = round(leftW + yearW + sp.x - scroll.left + c / 2);
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
{statRow}
{variant === 'streaks' && (
<div className="mb-4">
<p className="mb-2 text-[12px] font-medium text-muted-foreground">{t.streaks}</p>
{longest.length ? (
<ul className="flex flex-wrap gap-2">
{longest.map((r, k) => (
<li key={r.a}>
<button
type="button"
aria-pressed={picked === k}
onClick={() => setPicked(picked === k ? null : k)}
className={cn(
'inline-flex min-h-9 cursor-pointer items-center gap-2 rounded-lg border px-3 text-[13px] transition-[background-color,border-color,color] duration-200 max-sm:min-h-11',
picked === k ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border-strong))] bg-[color-mix(in_oklab,var(--primary)_9%,var(--card))] text-foreground' : 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground',
)}
>
<span className="font-semibold tabular-nums">{lenText(r)}</span>
<span className="tabular-nums">{fill(t.span, { a: short.format(dateOf(r.a)), b: short.format(dateOf(r.b)) })}</span>
</button>
</li>
))}
</ul>
) : (
<p className="text-[13px] text-muted-foreground">{t.none}</p>
)}
</div>
)}
<div ref={wrapRef}>
<ChartSurface {...surfaceBase}>
<div className="flex">
{/* Weekdays (and years) stay in place while the weeks scroll */}
<svg width={leftW + yearW} height={gridH} aria-hidden className="block shrink-0 overflow-visible">
{(variant === 'years' ? yearsList : [0]).map((yr, yi) => (
<g key={yr}>
{variant === 'years' && (
<text x={0} y={headH + yi * (blockH + yearGap) + blockH / 2} dy="0.34em" className="text-[12px] font-semibold tabular-nums" fill="var(--foreground)">
{yr}
</text>
)}
{Array.from({ length: 7 }, (_, r) =>
(r + start) % 7 === 1 || (r + start) % 7 === 3 || (r + start) % 7 === 5 ? (
<text key={r} x={yearW} y={round(headH + yi * (blockH + yearGap) + r * (c + GAP) + c / 2)} dy="0.34em" className="text-[11px]" fill="var(--muted-foreground)">
{wdShort.format(new Date(Date.UTC(2026, 0, 4 + ((r + start) % 7))))}
</text>
) : null,
)}
</g>
))}
</svg>
<div
ref={scrollRef}
onScroll={(e) => setScroll({ left: e.currentTarget.scrollLeft, max: e.currentTarget.scrollWidth - e.currentTarget.clientWidth })}
className={cn('kit-no-scrollbar relative min-w-0 flex-1 overflow-x-auto', scroll.max > 4 && (scroll.left < 4 ? 'kit-fade-end' : scroll.left >= scroll.max - 4 ? 'kit-fade-start' : 'kit-fade-x'))}
>
<div className="relative" style={{ width: gridW }} onPointerMove={pick} onPointerDown={pick}>
<svg width={gridW} height={gridH} viewBox={`0 0 ${gridW} ${gridH}`} aria-hidden className="block overflow-visible">
{marks.map((m) => (
<text key={m.x} x={m.x} y={11} className="text-[11px]" fill="var(--muted-foreground)">
{m.text}
</text>
))}
{vals.map((_, i) => {
const p = posOf(i);
const fut = future(i);
return (
<rect
key={i}
x={p.x}
y={p.y}
width={c}
height={c}
rx={c >= 14 ? 4 : 3}
fill={fillOf(i)}
stroke={fut ? 'var(--kitch-grid)' : 'none'}
opacity={dimOf(i)}
className={cn('transition-opacity duration-300 motion-reduce:transition-none', anim && 'kitch-cell')}
style={delay((p.x / Math.max(1, gridW)) * 700 + (p.y / Math.max(1, gridH)) * 120)}
/>
);
})}
{/* The picked streak: a bracket under its weeks, the other days step back */}
{pickedRun &&
(() => {
const x0 = posOf(pickedRun.a).x;
const x1 = posOf(pickedRun.b).x + c;
const y = headH + blockH + 8;
const text = lenText(pickedRun);
const tx = Math.min(Math.max((x0 + x1) / 2, textWidth(text, 11) / 2), gridW - textWidth(text, 11) / 2);
return (
<g className={anim ? 'kitch-fade' : undefined} style={delay(800)}>
<path d={`M${x0} ${y - 4}V${y}H${x1}V${y - 4}`} fill="none" stroke="var(--primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<text x={round(tx)} y={y + 13} textAnchor="middle" className="text-[11px] font-semibold tabular-nums" fill="var(--foreground)">
{text}
</text>
</g>
);
})()}
{shown !== null && <rect x={-1.5} y={-1.5} width={c + 3} height={c + 3} rx={4} fill="none" stroke="var(--foreground)" strokeWidth="1.5" className="kitch-glide pointer-events-none" data-jump={jump ? '' : undefined} style={{ transform: `translate(${sp.x}px, ${sp.y}px)`, opacity: open ? 1 : 0 }} />}
</svg>
</div>
</div>
{variant === 'years' && !phone && (
<svg width={totalW} height={gridH} aria-hidden className="block shrink-0 overflow-visible">
{yearsList.map((yr, yi) => {
const sum = vals.reduce((a, v, i) => (dateOf(i).getUTCFullYear() === yr ? a + v : a), 0);
return (
<text key={yr} x={totalW} y={headH + yi * (blockH + yearGap) + blockH / 2} dy="0.34em" textAnchor="end" className="text-[13px] font-semibold tabular-nums" fill="var(--foreground)">
{fmt(sum)}
</text>
);
})}
</svg>
)}
</div>
{shown !== null && <ChartTooltip open={open} jump={jump} x={tipX} y={Math.max(0, sp.y - 8)} width={width} offset={c / 2 + 8} title={long.format(dateOf(shown))} rows={[{ key: 'v', label: valueLabel, value: future(shown) ? ct.noData : fmt(vals[shown]) }, ...(variant === 'streaks' && runOf(shown) ? [{ key: 's', label: t.streak, value: lenText(runOf(shown)!) }] : [])]} />}
</ChartSurface>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
{variant === 'years' && phone ? (
<p className="flex flex-wrap gap-x-3 gap-y-1 text-[12px] text-muted-foreground">
{yearsList.map((yr) => (
<span key={yr} className="tabular-nums">
<span className="font-medium text-foreground">{yr}</span> {fmt(vals.reduce((a, v, i) => (dateOf(i).getUTCFullYear() === yr ? a + v : a), 0))}
</span>
))}
</p>
) : (
<span />
)}
{key}
</div>
</ChartRoot>
</ChartFigure>
);
}Frequent questions
How do I install Calendar heatmap?
With aiw add @ai-website/calendar-heatmap. The command puts components/ui/calendar-heatmap.tsx into your project, the code is yours from then on. It brings along chart and chart-figure. It needs no further npm packages.
Which variants does Calendar heatmap have?
4 variants, Year, Month calendars, Several years and Streaks. You pick one with the variant prop, for example variant="months".
Can I translate the texts of Calendar heatmap?
Yes. Calendar heatmap carries 14 texts of its own in English and German, such as “Total”, “Active days” and “Best day”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Calendar heatmap?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Calendar heatmap with aiw_components and install it with aiw_add @ai-website/calendar-heatmap. Name the variant in your prompt, for example variant="year", and the assistant writes your content straight into the props.