Chart Heatmap
Original · freecontribution-style heatmap grid
byWeb InnoventixReact + Tailwind
chartheatmapcharts
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/chart-heatmap.jsonchart-heatmap.tsx
"use client";
import { useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type CalDay = {
date: Date;
row: number;
col: number;
index: number;
};
type MonthLabel = { col: number; text: string };
type Calendar = {
days: CalDay[];
matrix: CalDay[][];
monthLabels: MonthLabel[];
start: Date;
end: Date;
};
type Metric = {
id: string;
label: string;
unit: string;
seed: number;
scale: number;
phase: number;
};
type Stats = {
total: number;
activeDays: number;
longest: number;
current: number;
bestIndex: number;
bestCount: number;
};
type Dataset = { counts: number[]; stats: Stats };
const WEEKS = 53;
const ROWS = 7;
const TOTAL_DAYS = WEEKS * ROWS;
const CELL = 13;
const GAP = 3;
const PITCH = CELL + GAP;
const MONTH_H = 18;
const HEADER_GAP = 4;
const WEEK_COL_W = 30;
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const MONTHS_SHORT = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const WEEKDAYS = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
const WEEKDAY_LABELS: { row: number; text: string }[] = [
{ row: 1, text: "Mon" },
{ row: 3, text: "Wed" },
{ row: 5, text: "Fri" },
];
const METRICS: Metric[] = [
{ id: "commits", label: "Commits", unit: "commits", seed: 0x9e3779b1, scale: 14, phase: 0 },
{ id: "reviews", label: "Reviews", unit: "reviews", seed: 0x1b873593, scale: 8, phase: 1.7 },
{ id: "issues", label: "Issues", unit: "issues", seed: 0x27d4eb2f, scale: 6, phase: 3.4 },
];
const LEVEL_CLASSES = [
"bg-slate-200/80 dark:bg-slate-800",
"bg-emerald-200 dark:bg-emerald-900",
"bg-emerald-300 dark:bg-emerald-700",
"bg-emerald-400 dark:bg-emerald-600",
"bg-emerald-500 dark:bg-emerald-400",
];
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function buildCalendar(): Calendar {
const anchor = new Date(2026, 5, 27);
const end = new Date(anchor);
end.setDate(end.getDate() + (6 - end.getDay()));
const start = new Date(end);
start.setDate(start.getDate() - (TOTAL_DAYS - 1));
const days: CalDay[] = [];
const matrix: CalDay[][] = Array.from({ length: ROWS }, () => []);
for (let i = 0; i < TOTAL_DAYS; i++) {
const date = new Date(start);
date.setDate(start.getDate() + i);
const row = i % ROWS;
const col = Math.floor(i / ROWS);
const day: CalDay = { date, row, col, index: i };
days.push(day);
matrix[row][col] = day;
}
const monthLabels: MonthLabel[] = [];
let lastMonth = -1;
for (let col = 0; col < WEEKS; col++) {
const month = matrix[0][col].date.getMonth();
if (month !== lastMonth) {
monthLabels.push({ col, text: MONTHS_SHORT[month] });
lastMonth = month;
}
}
return { days, matrix, monthLabels, start, end };
}
function buildDataset(days: CalDay[], metric: Metric): Dataset {
const rng = mulberry32(metric.seed);
const counts: number[] = new Array(days.length);
for (let i = 0; i < days.length; i++) {
const day = days[i];
const weekend = day.row === 0 || day.row === 6;
const roll = rng();
const size = rng();
const idleChance = weekend ? 0.5 : 0.16;
let count = 0;
if (roll > idleChance) {
const wave = 0.7 + 0.5 * Math.sin((day.col / WEEKS) * Math.PI * 2 + metric.phase);
const base = Math.pow(size, 2.1) * metric.scale * (weekend ? 0.55 : 1);
count = Math.max(0, Math.round(base * wave));
}
counts[i] = count;
}
let total = 0;
let activeDays = 0;
let longest = 0;
let run = 0;
let bestIndex = 0;
let bestCount = -1;
for (let i = 0; i < counts.length; i++) {
const count = counts[i];
total += count;
if (count > 0) {
activeDays++;
run++;
if (run > longest) longest = run;
} else {
run = 0;
}
if (count > bestCount) {
bestCount = count;
bestIndex = i;
}
}
let current = 0;
for (let i = counts.length - 1; i >= 0; i--) {
if (counts[i] > 0) current++;
else break;
}
return { counts, stats: { total, activeDays, longest, current, bestIndex, bestCount } };
}
function levelFor(count: number, scale: number): number {
if (count <= 0) return 0;
const q = count / scale;
if (q < 0.25) return 1;
if (q < 0.5) return 2;
if (q < 0.78) return 3;
return 4;
}
function addCommas(value: number): string {
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
function formatFull(date: Date): string {
return `${WEEKDAYS[date.getDay()]}, ${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
}
function formatShort(date: Date): string {
return `${MONTHS_SHORT[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
}
function PulseIcon() {
return (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 12h4l2 6 4-15 2 9h6" />
</svg>
);
}
function CalendarIcon() {
return (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="5" width="18" height="16" rx="2.5" />
<path d="M16 3v4M8 3v4M3 11h18" />
</svg>
);
}
function FlameIcon() {
return (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 2.5c3.2 3.1 5.2 6 5.2 9.4a5.2 5.2 0 0 1-10.4 0c0-1.6.6-3 1.7-4.2.5 1.1 1.2 1.7 2 1.9-.4-2.4.1-4.7 1.5-7.1Z" />
</svg>
);
}
function SparkIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8" />
</svg>
);
}
export default function ChartHeatmap() {
const reduced = useReducedMotion();
const calendar = useMemo(() => buildCalendar(), []);
const datasets = useMemo(() => {
const map: Record<string, Dataset> = {};
for (const metric of METRICS) map[metric.id] = buildDataset(calendar.days, metric);
return map;
}, [calendar]);
const [metricId, setMetricId] = useState<string>(METRICS[0].id);
const [focus, setFocus] = useState<{ row: number; col: number }>({ row: 6, col: WEEKS - 1 });
const [active, setActive] = useState<{ row: number; col: number; x: number; y: number } | null>(
null,
);
const cardRef = useRef<HTMLDivElement | null>(null);
const cellRefs = useRef<(HTMLButtonElement | null)[][]>([]);
const radioRefs = useRef<(HTMLButtonElement | null)[]>([]);
const metric = METRICS.find((m) => m.id === metricId) ?? METRICS[0];
const { counts, stats } = datasets[metricId] ?? datasets[METRICS[0].id];
const singular = metric.unit.endsWith("s") ? metric.unit.slice(0, -1) : metric.unit;
const activeDay = active ? calendar.matrix[active.row][active.col] : null;
const activeCount = activeDay ? counts[activeDay.index] : 0;
const bestDate = calendar.days[stats.bestIndex].date;
const rangeText = `${MONTHS_SHORT[calendar.start.getMonth()]} ${calendar.start.getFullYear()} – ${
MONTHS_SHORT[calendar.end.getMonth()]
} ${calendar.end.getFullYear()}`;
const activate = (row: number, col: number, el: HTMLButtonElement) => {
const card = cardRef.current;
if (!card) return;
const cardRect = card.getBoundingClientRect();
const cellRect = el.getBoundingClientRect();
setActive({
row,
col,
x: cellRect.left - cardRect.left + cellRect.width / 2,
y: cellRect.top - cardRect.top,
});
};
const labelFor = (day: CalDay, count: number) => {
const amount = count > 0 ? `${count} ${count === 1 ? singular : metric.unit}` : `No ${metric.unit}`;
return `${amount} on ${formatFull(day.date)}`;
};
const cardMotion = reduced
? {}
: {
initial: { opacity: 0, y: 18 },
whileInView: { opacity: 1, y: 0 },
viewport: { once: true, amount: 0.15 },
transition: { duration: 0.6, ease: "easeOut" as const },
};
const tiles = [
{
key: "total",
label: `Total ${metric.unit}`,
value: addCommas(stats.total),
sub: "this year",
icon: <PulseIcon />,
},
{
key: "active",
label: "Active days",
value: addCommas(stats.activeDays),
sub: `of ${TOTAL_DAYS}`,
icon: <CalendarIcon />,
},
{
key: "longest",
label: "Longest streak",
value: addCommas(stats.longest),
sub: "days",
icon: <FlameIcon />,
},
{
key: "current",
label: "Current streak",
value: addCommas(stats.current),
sub: "days",
icon: <FlameIcon />,
},
];
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 dark:bg-slate-950 sm:px-6 sm:py-20 lg:px-8">
<style>{`
@keyframes chm-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
50% { box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.4); }
}
@keyframes chm-rise {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.chm-anim { animation: none !important; }
.chm-cell { transition: none !important; transform: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-28 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-emerald-400/10 blur-3xl dark:bg-emerald-500/10"
/>
<motion.div
ref={cardRef}
{...cardMotion}
className="relative mx-auto max-w-5xl rounded-3xl border border-slate-200 bg-white p-5 shadow-xl shadow-slate-950/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/30 sm:p-8"
>
<div className="mb-6 flex flex-col gap-5 sm:mb-8 sm:flex-row sm:items-end sm:justify-between">
<div>
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-600 dark:text-emerald-400">
<SparkIcon />
Activity graph
</span>
<h2 className="mt-2.5 text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-50 sm:text-3xl">
A year of shipping
</h2>
<p className="mt-2 max-w-md text-sm leading-relaxed text-slate-500 dark:text-slate-400">
Every square is one day between {rangeText}. Hover a square, or focus the grid and
walk it with the arrow keys.
</p>
</div>
<div
role="radiogroup"
aria-label="Choose which metric to plot"
className="inline-flex shrink-0 self-start rounded-full border border-slate-200 bg-slate-100 p-1 dark:border-slate-700 dark:bg-slate-800 sm:self-auto"
>
{METRICS.map((m, i) => {
const on = m.id === metric.id;
return (
<button
key={m.id}
ref={(el) => {
radioRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => setMetricId(m.id)}
onKeyDown={(e) => {
if (
e.key !== "ArrowRight" &&
e.key !== "ArrowLeft" &&
e.key !== "ArrowDown" &&
e.key !== "ArrowUp"
) {
return;
}
e.preventDefault();
const dir = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : -1;
const next = (i + dir + METRICS.length) % METRICS.length;
setMetricId(METRICS[next].id);
radioRefs.current[next]?.focus();
}}
className={`rounded-full px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-800 ${
on
? "bg-white text-emerald-700 shadow-sm dark:bg-slate-950 dark:text-emerald-300"
: "text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
}`}
>
{m.label}
</button>
);
})}
</div>
</div>
<div className="mb-7 grid grid-cols-2 gap-3 sm:grid-cols-4">
{tiles.map((tile) => (
<div
key={tile.key}
className="rounded-2xl border border-slate-200 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-800/40"
>
<div className="flex items-center gap-1.5 text-slate-500 dark:text-slate-400">
{tile.icon}
<span className="text-[11px] font-medium uppercase tracking-wide">{tile.label}</span>
</div>
<div className="mt-2 flex items-baseline gap-1.5">
<span className="text-2xl font-semibold tabular-nums text-slate-900 dark:text-slate-50">
{tile.value}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">{tile.sub}</span>
</div>
</div>
))}
</div>
<div className="overflow-x-auto pb-2">
<div className="flex w-max px-1" style={{ gap: HEADER_GAP }}>
<div
aria-hidden="true"
className="relative shrink-0"
style={{ width: WEEK_COL_W, height: MONTH_H + HEADER_GAP + ROWS * PITCH - GAP }}
>
{WEEKDAY_LABELS.map((label) => (
<span
key={label.text}
className="absolute right-1.5 text-[10px] leading-none text-slate-500 dark:text-slate-400"
style={{ top: MONTH_H + HEADER_GAP + label.row * PITCH + 3 }}
>
{label.text}
</span>
))}
</div>
<div className="flex flex-col" style={{ gap: HEADER_GAP }}>
<div
aria-hidden="true"
className="relative shrink-0"
style={{ height: MONTH_H, width: WEEKS * PITCH }}
>
{calendar.monthLabels.map((label) => (
<span
key={label.col}
className="absolute top-0 text-[10px] font-medium leading-none text-slate-500 dark:text-slate-400"
style={{ left: label.col * PITCH }}
>
{label.text}
</span>
))}
</div>
<div
role="grid"
aria-label={`${metric.label} heatmap, ${addCommas(stats.total)} ${metric.unit} across ${TOTAL_DAYS} days. Use the arrow keys to move between days.`}
className="flex flex-col outline-none"
style={{ gap: GAP }}
onMouseLeave={() => setActive(null)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setActive(null);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
if (active) {
e.preventDefault();
setActive(null);
}
return;
}
let { row, col } = focus;
if (e.key === "ArrowRight") col = Math.min(WEEKS - 1, col + 1);
else if (e.key === "ArrowLeft") col = Math.max(0, col - 1);
else if (e.key === "ArrowDown") row = Math.min(ROWS - 1, row + 1);
else if (e.key === "ArrowUp") row = Math.max(0, row - 1);
else if (e.key === "Home") col = 0;
else if (e.key === "End") col = WEEKS - 1;
else return;
e.preventDefault();
setFocus({ row, col });
cellRefs.current[row]?.[col]?.focus();
}}
>
{Array.from({ length: ROWS }, (_, row) => (
<div key={row} role="row" className="flex" style={{ gap: GAP }}>
{Array.from({ length: WEEKS }, (_, col) => {
const day = calendar.matrix[row][col];
const count = counts[day.index];
const level = levelFor(count, metric.scale);
const isBest = day.index === stats.bestIndex;
const isFocus = focus.row === row && focus.col === col;
return (
<button
key={col}
ref={(el) => {
if (!cellRefs.current[row]) cellRefs.current[row] = [];
cellRefs.current[row][col] = el;
}}
type="button"
role="gridcell"
tabIndex={isFocus ? 0 : -1}
aria-label={labelFor(day, count)}
onMouseEnter={(e) => activate(row, col, e.currentTarget)}
onFocus={(e) => {
setFocus({ row, col });
activate(row, col, e.currentTarget);
}}
onClick={() => setFocus({ row, col })}
className={`${LEVEL_CLASSES[level]} ${
isBest ? "chm-anim" : ""
} chm-cell shrink-0 rounded-[3px] outline-none ring-emerald-500 transition-transform duration-150 hover:scale-125 focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900`}
style={{
width: CELL,
height: CELL,
animation:
isBest && !reduced ? "chm-glow 2.6s ease-in-out infinite" : undefined,
}}
/>
);
})}
</div>
))}
</div>
</div>
</div>
</div>
<div className="mt-5 flex flex-col gap-3 border-t border-slate-100 pt-5 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
<p
className="chm-anim text-xs text-slate-500 dark:text-slate-400"
style={{ animation: reduced ? undefined : "chm-rise 0.6s ease-out both" }}
>
Busiest day —{" "}
<span className="font-medium text-slate-800 dark:text-slate-100">
{formatShort(bestDate)}
</span>{" "}
with {stats.bestCount} {stats.bestCount === 1 ? singular : metric.unit}.
</p>
<div
aria-hidden="true"
className="flex items-center gap-1.5 text-[11px] text-slate-500 dark:text-slate-400"
>
<span>Less</span>
{LEVEL_CLASSES.map((cls, i) => (
<span
key={i}
className={`${cls} rounded-[3px]`}
style={{ width: CELL, height: CELL }}
/>
))}
<span>More</span>
</div>
</div>
{active && activeDay && (
<motion.div
key="chm-tip"
aria-hidden="true"
initial={reduced ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.12, ease: "easeOut" }}
className="pointer-events-none absolute z-30 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-lg border border-slate-700 bg-slate-900 px-2.5 py-1.5 text-xs shadow-lg dark:border-slate-600 dark:bg-slate-800"
style={{ left: active.x, top: active.y - 10 }}
>
<span className="font-semibold text-white">
{activeCount > 0
? `${activeCount} ${activeCount === 1 ? singular : metric.unit}`
: `No ${metric.unit}`}
</span>
<span className="text-slate-300 dark:text-slate-400">
{" · "}
{formatShort(activeDay.date)}
</span>
<span className="absolute left-1/2 top-full h-2 w-2 -translate-x-1/2 -translate-y-1/2 rotate-45 border-b border-r border-slate-700 bg-slate-900 dark:border-slate-600 dark:bg-slate-800" />
</motion.div>
)}
</motion.div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Chart Bar
Originalhand-built SVG/div bar chart with axes

Chart Bar Grouped
Originalgrouped bar chart with legend

Chart Line
OriginalSVG line chart with points and grid

Chart Area
OriginalSVG area chart with gradient fill

Chart Donut
OriginalSVG donut chart with centre label

Chart Pie
OriginalSVG pie chart with legend

Chart Radial
Originalradial progress bars chart

Chart Sparkline
Originalinline sparklines in stat rows

Chart Gauge
Originalsemicircle gauge chart

Chart Stacked Bar
Originalstacked bar chart

Chart Progress
Originalhorizontal progress/metric chart

Chart Bubble
OriginalSVG bubble/scatter chart

