Feedback & Data
HeatCalendar
A contribution calendar raked out as a bed of coals in an iron tray. Each lump is a day, from cold ash through dull red to white-gold. The hot ones breathe, hover blows on them and reads the day on a tag, arrow keys walk the days.
Download
- React
- Tailwind CSS v4
- motion
components/ui/heat-calendar.tsx
"use client";
import { useId, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
type HeatCalendarProps = {
/** Values by date, like { "2026-10-12": 8 }. Leave out for sample data. */
data?: Record<string, number>;
/** Last day shown, as YYYY-MM-DD. */
end?: string;
/** Weeks shown, each a column. */
weeks?: number;
/** Word for one count, in the tooltip and total. */
unit?: string;
/** Called when a day is pressed or Enter is hit on it. */
onSelect?: (date: string, value: number) => void;
/** Set the width here. Everything scales with it. */
className?: string;
};
type Day = { date: string; label: string; value: number; level: number; col: number; row: number; blob: string };
const CELL = 19;
const LEFT = 42;
const TOP = 44;
const DAY_MS = 86400000;
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const HEAT = ["#2c2826", "#5e2210", "#a5330f", "#e8660f", "#ffc34d"]; // cold to hot, one steady ramp
const hash = (n: number) => {
let h = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b);
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
};
// A lump of coal: a rough ring of points, the same on server and browser.
const lump = (x: number, y: number, seed: number) => {
let d = "";
for (let i = 0; i < 7; i++) {
const a = (i / 7) * Math.PI * 2 + hash(seed + 99);
const r = 7.2 + hash(seed * 7 + i) * 1.9;
d += `${d ? "L" : "M"}${(x + Math.cos(a) * r).toFixed(1)} ${(y + Math.sin(a) * r).toFixed(1)}`;
}
return `${d}Z`;
};
const sample = (i: number, weekday: number) => {
const rest = weekday >= 5 ? 0.3 : 1;
const wave = 0.55 + 0.45 * Math.sin(i / 9);
const v = hash(i + 7) * wave * rest;
return v < 0.1 ? 0 : Math.round(v * 16);
};
/* HeatCalendar: a contribution calendar raked out as a bed of coals in an iron tray. Each lump is a day: cold ash for none, up through dull red to white-gold for your hottest days. The hot ones breathe. Hover blows on the coals and reads the day out on a tag. */
export function HeatCalendar({ data, end = "2026-11-01", weeks = 26, unit = "commits", onSelect, className = "w-full max-w-2xl" }: HeatCalendarProps) {
const reduce = useReducedMotion();
const id = useId().replace(/:/g, "");
const svg = useRef<SVGSVGElement>(null);
const [active, setActive] = useState<number | null>(null);
const [puff, setPuff] = useState<{ n: number; x: number; y: number } | null>(null);
const [breath, setBreath] = useState<{ x: number; y: number } | null>(null);
const { days, months, total, max } = useMemo(() => {
const last = Date.parse(`${end}T00:00:00Z`);
const lastRow = (new Date(last).getUTCDay() + 6) % 7;
const first = last - ((weeks - 1) * 7 + lastRow) * DAY_MS;
const raw: { date: string; ms: number; value: number; i: number }[] = [];
for (let i = 0, ms = first; ms <= last; i++, ms += DAY_MS) {
const date = new Date(ms).toISOString().slice(0, 10);
raw.push({ date, ms, i, value: data ? (data[date] ?? 0) : sample(i, i % 7) });
}
const max = Math.max(1, ...raw.map((d) => d.value));
const days: Day[] = raw.map(({ date, ms, value, i }) => {
const d = new Date(ms);
const col = Math.floor(i / 7);
const row = i % 7;
return {
date,
value,
col,
row,
level: value === 0 ? 0 : Math.ceil((value / max) * 4),
label: `${DAYS[row]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`,
blob: lump(LEFT + col * CELL + CELL / 2, TOP + row * CELL + CELL / 2, i + 1),
};
});
const months: { name: string; col: number }[] = [];
days.forEach((d) => {
const name = MONTHS[Number(d.date.slice(5, 7)) - 1];
if (d.row === 0 && months.at(-1)?.name !== name) months.push({ name, col: d.col });
});
return { days, months, total: raw.reduce((sum, d) => sum + d.value, 0), max };
}, [data, end, weeks]);
const width = LEFT + weeks * CELL + 18;
const height = TOP + 7 * CELL + 40;
const shown = active === null ? null : days[active];
const tag = shown && {
x: Math.min(width - 150, Math.max(8, LEFT + shown.col * CELL - 56)),
y: shown.row < 2 ? TOP + shown.row * CELL + 26 : TOP + shown.row * CELL - 30,
};
const pick = (i: number) => {
const d = days[i];
setPuff((p) => ({ n: (p?.n ?? 0) + 1, x: LEFT + d.col * CELL + CELL / 2, y: TOP + d.row * CELL + CELL / 2 }));
onSelect?.(d.date, d.value);
};
const move = (e: React.KeyboardEvent) => {
const step = { ArrowLeft: -7, ArrowRight: 7, ArrowUp: -1, ArrowDown: 1 }[e.key];
if (step) {
e.preventDefault();
setActive((a) => Math.min(days.length - 1, Math.max(0, (a ?? days.length - 1) + step)));
} else if ((e.key === "Enter" || e.key === " ") && active !== null) {
e.preventDefault();
pick(active);
}
};
return (
<div className={`select-none ${className}`}>
<style>{`
.${id}-b{animation:${id}-b var(--s) ease-in-out var(--d) infinite}
@keyframes ${id}-b{0%,100%{opacity:.34}50%{opacity:.82}}
@media (prefers-reduced-motion:reduce){.${id}-b{animation:none;opacity:.6}}
`}</style>
<svg
ref={svg}
viewBox={`0 0 ${width} ${height}`}
className="block h-auto w-full rounded-[1.5%] outline-offset-2"
role="group"
tabIndex={0}
aria-label={`Bed of coals calendar, ${total} ${unit} in ${weeks} weeks. Arrow keys read each day.`}
onKeyDown={move}
onPointerDown={(e) => e.preventDefault()}
onFocus={() => setActive((a) => a ?? days.length - 1)}
onBlur={() => setActive(null)}
onPointerMove={(e) => {
const box = svg.current?.getBoundingClientRect();
if (box) setBreath({ x: ((e.clientX - box.left) / box.width) * width, y: ((e.clientY - box.top) / box.height) * height });
}}
onPointerLeave={() => {
setActive(null);
setBreath(null);
}}
>
<defs>
<linearGradient id={`${id}iron`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#4a4644" />
<stop offset="1" stopColor="#242120" />
</linearGradient>
<radialGradient id={`${id}glow`}>
<stop offset="0" stopColor="#ff8a2b" stopOpacity="0.85" />
<stop offset="1" stopColor="#ff5a10" stopOpacity="0" />
</radialGradient>
<radialGradient id={`${id}air`}>
<stop offset="0" stopColor="#ffb05a" stopOpacity="0.5" />
<stop offset="1" stopColor="#ff7a20" stopOpacity="0" />
</radialGradient>
</defs>
{/* The tray. */}
<rect width={width} height={height} rx="9" fill={`url(#${id}iron)`} />
<rect x={LEFT - 5} y={TOP - 5} width={weeks * CELL + 10} height={7 * CELL + 10} rx="4" fill="#0e0c0b" stroke="#000" strokeWidth="1.5" />
{[[9, 9], [width - 9, 9], [9, height - 9], [width - 9, height - 9]].map(([x, y]) => (
<circle key={`${x}${y}`} cx={x} cy={y} r="3.2" fill="#6b6663" stroke="#1a1817" />
))}
<g fontFamily="ui-monospace, Consolas, monospace" fontSize="11.5" fill="#bdb6ae">
{months.map((m) => (
<text key={`${m.name}${m.col}`} x={LEFT + m.col * CELL + 2} y={TOP - 14}>{m.name}</text>
))}
{[0, 2, 4].map((r) => (
<text key={r} x={LEFT - 10} y={TOP + r * CELL + 13.5} textAnchor="end">{DAYS[r]}</text>
))}
<text x={LEFT} y={height - 13}>{total} {unit}</text>
<text x={width - 124} y={height - 13} textAnchor="end">Cold</text>
<text x={width - 18} y={height - 13} textAnchor="end">Hot</text>
</g>
{HEAT.map((c, i) => (
<path key={c} d={lump(width - 111 + i * 15.5, height - 17, i + 3)} fill={c} transform={`translate(${(width - 111 + i * 15.5) * 0.25} ${(height - 17) * 0.25}) scale(0.75)`} />
))}
{/* The coals: glow under, lump on top. */}
{days.map((d) =>
d.level >= 2 ? (
<circle
key={d.date}
className={`${id}-b pointer-events-none`}
cx={LEFT + d.col * CELL + CELL / 2}
cy={TOP + d.row * CELL + CELL / 2}
r={9 + d.level * 3.5}
fill={`url(#${id}glow)`}
style={{ "--s": `${2.4 + ((d.col * 7 + d.row * 3) % 19) / 10}s`, "--d": `-${((d.col * 5 + d.row * 11) % 31) / 10}s` } as React.CSSProperties}
/>
) : null,
)}
{days.map((d, i) => (
<g key={d.date} onPointerEnter={() => setActive(i)} onClick={() => pick(i)} className="cursor-pointer">
<rect x={LEFT + d.col * CELL} y={TOP + d.row * CELL} width={CELL} height={CELL} fill="transparent" />
<path d={d.blob} fill={HEAT[d.level]} stroke={active === i ? "#fff3d6" : "#0006"} strokeWidth={active === i ? 1.6 : 0.8} strokeLinejoin="round" />
</g>
))}
{breath && !reduce && <circle className="pointer-events-none" cx={breath.x} cy={breath.y} r="52" fill={`url(#${id}air)`} style={{ mixBlendMode: "screen" }} />}
{puff && !reduce && (
<g key={puff.n} className="pointer-events-none">
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
<motion.circle
key={i}
cx={puff.x}
cy={puff.y}
r="1.6"
fill="#ffd27a"
initial={{ x: 0, y: 0, opacity: 1 }}
animate={{ x: (hash(puff.n * 9 + i) - 0.5) * 34, y: -18 - hash(puff.n + i * 3) * 30, opacity: 0 }}
transition={{ duration: 0.75, ease: "easeOut" }}
/>
))}
</g>
)}
{/* The tag. */}
{shown && tag && (
<g className="pointer-events-none">
<rect x={tag.x} y={tag.y} width="142" height="24" rx="3" fill="#ece3d0" stroke="#000" strokeWidth="1" />
<circle cx={tag.x + 9} cy={tag.y + 12} r="2.4" fill="#242120" />
<text x={tag.x + 17} y={tag.y + 16.5} fontSize="12" fontFamily="ui-monospace, Consolas, monospace" fill="#1d1a18">
{shown.label}: {shown.value}
</text>
</g>
)}
</svg>
<span role="status" className="sr-only">{shown ? `${shown.label}, ${shown.value} ${unit}, highest is ${max}` : ""}</span>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/heat-calendar.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/heat-calendar-demo.tsx
import { HeatCalendar } from "@/components/ui/heat-calendar"; export default function HeatCalendarExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#191614] p-2"> <HeatCalendar weeks={20} className="w-[min(200cqh,96cqw,720px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| data | Record<string, number> | sample data | Values by date, like { "2026-10-12": 8 }. |
| end | string | "2026-11-01" | Last day shown, as YYYY-MM-DD. |
| weeks | number | 26 | Weeks shown, each a column. |
| unit | string | "commits" | Word for one count. |
| onSelect | (date: string, value: number) => void | - | Called when a day is pressed. |
| className | string | "w-full max-w-2xl" | Set the width here. Everything scales with it. |