A GitHub-style contribution grid with a 5-step single-hue scale, row-staggered fade-in, month labels, and per-cell tooltips.
npx shadcn@latest add @paragon/heatmap-calendar"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
const GAP = 3;
const MONTH_ROW = 18; // vertical space reserved for month labels
const LEVELS = 5; // 0 = empty, 1..4 = intensity buckets
const DAY_LABEL_ROWS = [1, 3, 5] as const; // Mon / Wed / Fri
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTH_NAMES = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
function parseDay(iso: string) {
return new Date(`${iso}T00:00:00Z`);
}
function addDays(date: Date, days: number) {
return new Date(date.getTime() + days * 86400000);
}
function isoDay(date: Date) {
return date.toISOString().slice(0, 10);
}
/**
* Intensity ramp: mix the accent color into the surface in five steps.
* Level 0 is a faint neutral "empty" cell; 1..4 climb toward the full accent.
*/
function levelFill(level: number, color: string) {
if (level <= 0) return "color-mix(in oklab, var(--muted-foreground) 12%, transparent)";
const pct = [22, 44, 70, 100][Math.min(3, level - 1)];
return `color-mix(in oklab, ${color} ${pct}%, var(--card))`;
}
export interface HeatmapDatum {
/** ISO date, e.g. "2026-03-14". */
date: string;
count: number;
}
export interface HeatmapCalendarProps
extends Omit<React.ComponentProps<"div">, "onSelect"> {
data: HeatmapDatum[];
/** Last day shown. Defaults to the latest date in `data`. */
endDate?: string;
/** Number of week columns to render (counting back from endDate). */
weeks?: number;
/** Cell edge length in px. Gap scales with it. */
cellSize?: number;
/** Accent color for the intensity ramp. */
color?: string;
/** Weekday labels (Mon/Wed/Fri) down the left edge. */
showWeekdays?: boolean;
/** Legend + total footer. */
showLegend?: boolean;
/** Tooltip line for a cell, e.g. `(3) => "3 deploys"`. */
countLabel?: (count: number) => string;
/** Noun used in the total, e.g. "deploys". */
totalNoun?: string;
/** Accessible description of the chart. */
label?: string;
/** Animation speed multiplier for the enter stagger. */
speed?: number;
/** Renders the final state immediately, no fade-in. */
static?: boolean;
}
/**
* A GitHub-contributions-style calendar. The grid is computed from the date
* sequence: week columns count back from `endDate`, weekday rows are Sun→Sat,
* and partial first/last weeks are clipped to the range. Month labels sit
* exactly above the first week that opens each month, weekday labels align to
* their rows, and colors bucket the values into five accent-mixed steps.
*
* Cells fade + scale in with a diagonal stagger on first view (once), each is
* keyboard-focusable, and hovering or focusing anchors a date-and-value
* tooltip. Reduced motion renders the final state immediately.
*/
export function HeatmapCalendar({
data,
endDate,
weeks = 26,
cellSize = 12,
color = "var(--primary)",
showWeekdays = true,
showLegend = true,
countLabel = (count) =>
`${count === 0 ? "No" : count.toLocaleString("en-US")} ${
count === 1 ? "contribution" : "contributions"
}`,
totalNoun = "contributions",
label,
speed = 1,
static: isStatic = false,
className,
...props
}: HeatmapCalendarProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(containerRef, {
once: true,
margin: "0px 0px -24px 0px",
});
const [tip, setTip] = React.useState<{
x: number;
y: number;
iso: string;
text: string;
visible: boolean;
} | null>(null);
const animate = !isStatic && !reducedMotion;
const drawn = !animate || inView;
const CELL = cellSize;
const PITCH = CELL + GAP;
// Left gutter: room for weekday labels; else a small breathing edge.
const GUTTER = showWeekdays ? Math.round(CELL * 2.4) + 8 : 2;
const counts = React.useMemo(() => {
const map = new Map<string, number>();
for (const d of data) map.set(d.date, (map.get(d.date) ?? 0) + d.count);
return map;
}, [data]);
// Quantile thresholds over the non-zero values — buckets 1..4.
const thresholds = React.useMemo(() => {
const nonzero = data
.map((d) => d.count)
.filter((c) => c > 0)
.sort((a, b) => a - b);
if (nonzero.length === 0) return [1, 1, 1, 1];
const q = (p: number) =>
nonzero[Math.min(nonzero.length - 1, Math.floor(p * nonzero.length))];
return [q(0.25), q(0.5), q(0.75), q(0.9)];
}, [data]);
const levelFor = React.useCallback(
(count: number) => {
if (count <= 0) return 0;
let lvl = 1;
for (let i = 0; i < thresholds.length; i++) {
if (count >= thresholds[i]) lvl = i + 1;
}
return Math.min(LEVELS - 1, lvl);
},
[thresholds],
);
const end = React.useMemo(() => {
if (endDate) return parseDay(endDate);
if (data.length > 0) {
return parseDay(
data.reduce((latest, d) => (d.date > latest ? d.date : latest), data[0].date),
);
}
return parseDay("2026-01-01");
}, [endDate, data]);
// Grid geometry. Last column ends on the week containing `end`; count back.
const firstOfLastColumn = addDays(end, -end.getUTCDay());
const start = addDays(firstOfLastColumn, -(weeks - 1) * 7);
const gridW = weeks * PITCH - GAP;
const gridH = 7 * PITCH - GAP;
const svgWidth = GUTTER + gridW;
const svgHeight = MONTH_ROW + gridH;
const dateFormat = React.useMemo(
() =>
new Intl.DateTimeFormat("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}),
[],
);
// Build the visible cells + running total + month boundaries.
const { cells, total, monthLabels } = React.useMemo(() => {
const out: {
iso: string;
date: Date;
x: number;
y: number;
week: number;
row: number;
count: number;
level: number;
}[] = [];
let sum = 0;
const months: { week: number; text: string }[] = [];
let lastLabelWeek = -3;
for (let w = 0; w < weeks; w++) {
const columnFirst = addDays(start, w * 7);
// Month label above the first week whose Sunday opens a new month —
// and where a full label won't run off the right edge.
const prevColumnFirst = addDays(start, (w - 1) * 7);
const monthChanged =
w === 0 || columnFirst.getUTCMonth() !== prevColumnFirst.getUTCMonth();
if (monthChanged && w - lastLabelWeek >= 3 && w <= weeks - 3) {
months.push({ week: w, text: MONTH_NAMES[columnFirst.getUTCMonth()] });
lastLabelWeek = w;
}
for (let row = 0; row < 7; row++) {
const date = addDays(start, w * 7 + row);
if (date < start || date > end) continue; // clip partial edges
const iso = isoDay(date);
const count = counts.get(iso) ?? 0;
sum += count;
out.push({
iso,
date,
x: GUTTER + w * PITCH,
y: MONTH_ROW + row * PITCH,
week: w,
row,
count,
level: levelFor(count),
});
}
}
return { cells: out, total: sum, monthLabels: months };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [weeks, start.getTime(), end.getTime(), counts, levelFor, GUTTER, PITCH]);
const hasData = cells.length > 0 && data.some((d) => d.count > 0);
const enterStep = Math.max(60, 260 / speed); // total spread of the stagger
return (
<div
ref={containerRef}
data-slot="heatmap-calendar"
className={cn("relative w-fit", className)}
{...props}
>
{!hasData ? (
<div
className="flex h-24 items-center justify-center rounded-lg px-6 text-sm text-muted-foreground shadow-border"
style={{ minWidth: svgWidth }}
>
No data
</div>
) : (
<svg
role="img"
aria-label={label ?? `Activity calendar, ${weeks} weeks`}
width={svgWidth}
height={svgHeight}
viewBox={`0 0 ${svgWidth} ${svgHeight}`}
className="block max-w-full"
onPointerLeave={() => setTip((t) => (t ? { ...t, visible: false } : t))}
>
{/* Month labels — anchored to the start column of each month. */}
{monthLabels.map(({ week, text }) => (
<text
key={`m-${week}`}
x={GUTTER + week * PITCH}
y={11}
fontSize={10}
className="fill-muted-foreground select-none"
>
{text}
</text>
))}
{/* Weekday labels — vertically centered on their row. */}
{showWeekdays &&
DAY_LABEL_ROWS.map((row) => (
<text
key={`d-${row}`}
x={GUTTER - 8}
y={MONTH_ROW + row * PITCH + CELL / 2}
textAnchor="end"
dominantBaseline="central"
fontSize={10}
className="fill-muted-foreground select-none"
>
{DAY_NAMES[row]}
</text>
))}
{cells.map((cell, i) => {
const hovered = tip?.visible && tip.iso === cell.iso;
const delay = animate
? ((cell.week + cell.row) /
(weeks + 7)) *
enterStep
: 0;
return (
<rect
key={cell.iso}
x={cell.x}
y={cell.y}
width={CELL}
height={CELL}
rx={Math.max(2, Math.round(CELL * 0.22))}
fill={levelFill(cell.level, color)}
tabIndex={0}
role="img"
aria-label={`${countLabel(cell.count)}, ${dateFormat.format(cell.date)}`}
className="cursor-default outline-none [transform-box:fill-box] [transform-origin:center] focus-visible:[stroke:var(--ring)] focus-visible:[stroke-width:2px]"
stroke={hovered ? "var(--foreground)" : undefined}
strokeOpacity={hovered ? 0.5 : undefined}
onPointerEnter={() =>
setTip({
x: cell.x + CELL / 2,
y: cell.y,
iso: cell.iso,
text: `${countLabel(cell.count)} · ${dateFormat.format(cell.date)}`,
visible: true,
})
}
onFocus={() =>
setTip({
x: cell.x + CELL / 2,
y: cell.y,
iso: cell.iso,
text: `${countLabel(cell.count)} · ${dateFormat.format(cell.date)}`,
visible: true,
})
}
onBlur={() => setTip((t) => (t ? { ...t, visible: false } : t))}
style={{
opacity: drawn ? 1 : 0,
transform: drawn ? "scale(1)" : "scale(0.9)",
transition: animate
? `opacity 220ms var(--ease-out) ${delay}ms, transform 220ms var(--ease-out) ${delay}ms, stroke 120ms var(--ease-out)`
: "stroke 120ms var(--ease-out)",
willChange: drawn ? undefined : "opacity, transform",
}}
/>
);
})}
</svg>
)}
{/* Tooltip — anchored above the cell, tokened fade on appear. */}
{tip && (
<div
aria-hidden
className="pointer-events-none absolute top-0 left-0 z-10 rounded-md bg-popover px-2 py-1 text-xs whitespace-nowrap text-popover-foreground shadow-overlay transition-opacity"
style={{
opacity: tip.visible ? 1 : 0,
transitionDuration: "var(--duration-fast)",
transitionTimingFunction: "var(--ease-out)",
transform: `translate(${tip.x}px, ${tip.y}px) translate(-50%, calc(-100% - 6px))`,
}}
>
{tip.text}
</div>
)}
{showLegend && hasData && (
<div className="mt-3 flex items-center justify-between gap-4 text-[11px] text-muted-foreground">
<span className="tabular-nums">
<span className="font-medium text-foreground">
{total.toLocaleString("en-US")}
</span>{" "}
{totalNoun}
</span>
<span className="flex items-center gap-1.5">
Less
<span className="flex gap-[3px]" aria-hidden>
{Array.from({ length: LEVELS }, (_, level) => (
<span
key={level}
className="rounded-[2px]"
style={{
width: 10,
height: 10,
background: levelFill(level, color),
}}
/>
))}
</span>
More
</span>
</div>
)}
</div>
);
}