A GitHub-style daily activity heatmap with computed streaks, instant cell tooltips, arrow-key navigation, and a staggered fill-in on first view.
npx shadcn@latest add @paragon/streak-calendar"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { Flame } from "lucide-react";
import { cn } from "@/lib/utils";
export interface StreakDay {
/** Human date label used in the tooltip, e.g. "Jul 4". */
label: string;
/** Activity count for the day. 0 = no activity. Drives the intensity. */
value: number;
/** Marks the current day for emphasis. */
today?: boolean;
}
export interface StreakCalendarProps
extends Omit<React.ComponentProps<"div">, "color"> {
/** Chronological list of days, oldest first. */
days: StreakDay[];
/** Weekday labels, Sunday-first. Set to null to hide. */
weekdays?: string[] | null;
/** Accent color the intensity ramp is mixed from. */
accent?: string;
/** Cell edge length in px. */
cellSize?: number;
/** Metric noun for the tooltip, e.g. "deliveries". */
unit?: string;
/** Suppresses the cell fill-in stagger. */
static?: boolean;
}
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
/**
* A daily-activity heatmap (GitHub-style). Days are packed into week columns
* aligned to weekday rows; each cell's fill intensity is bucketed from its
* value and mixed off the accent so both themes stay legible. The grid is one
* tab stop with roving focus — arrows move a day (up/down) or a week
* (left/right), Home/End jump — and every cell carries an instant
* date+value tooltip (a heatmap is scanned, so no hover delay). Streaks are
* computed from the series. Cells fade in with a short in-view stagger;
* reduced motion fills them with no movement.
*/
export function StreakCalendar({
days,
weekdays = WEEKDAYS,
accent = "var(--color-success)",
cellSize = 14,
unit = "active",
static: isStatic = false,
className,
...props
}: StreakCalendarProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reducedMotion = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const animate = !isStatic && !reducedMotion;
const shown = !animate || inView;
const uid = React.useId();
const maxValue = React.useMemo(
() => days.reduce((m, d) => Math.max(m, d.value), 0),
[days],
);
// Streak math (a day counts toward a streak when value > 0).
const { currentStreak, longestStreak, activeDays } = React.useMemo(() => {
let current = 0;
let longest = 0;
let run = 0;
let active = 0;
for (let i = 0; i < days.length; i++) {
if (days[i].value > 0) {
run += 1;
active += 1;
if (run > longest) longest = run;
} else {
run = 0;
}
}
// Current = trailing run.
for (let i = days.length - 1; i >= 0; i--) {
if (days[i].value > 0) current += 1;
else break;
}
return { currentStreak: current, longestStreak: longest, activeDays: active };
}, [days]);
// Column-major packing: consecutive weeks become columns; the first column is
// offset by the starting weekday so rows line up under the weekday labels.
const startOffset = 0; // days[0] is treated as the top-left (row 0).
const totalSlots = startOffset + days.length;
const columns = Math.ceil(totalSlots / 7);
// Roving tabindex: one tab stop; arrows move focus between days.
const todayIdx = days.findIndex((d) => d.today);
const [focusIdx, setFocusIdx] = React.useState(() =>
todayIdx >= 0 ? todayIdx : Math.max(days.length - 1, 0),
);
const cellRefs = React.useRef(new Map<number, HTMLButtonElement>());
const moveFocus = (target: number) => {
const clamped = Math.min(Math.max(target, 0), days.length - 1);
setFocusIdx(clamped);
cellRefs.current.get(clamped)?.focus();
};
const handleKeyDown = (event: React.KeyboardEvent, i: number) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveFocus(i + 1);
break;
case "ArrowUp":
event.preventDefault();
moveFocus(i - 1);
break;
case "ArrowRight":
event.preventDefault();
moveFocus(i + 7);
break;
case "ArrowLeft":
event.preventDefault();
moveFocus(i - 7);
break;
case "Home":
event.preventDefault();
moveFocus(0);
break;
case "End":
event.preventDefault();
moveFocus(days.length - 1);
break;
}
};
// Intensity bucket 0–4 → mixed accent alpha.
const bucketColor = (value: number) => {
if (value <= 0 || maxValue <= 0) return "var(--color-secondary)";
const t = value / maxValue; // 0–1
const bucket = Math.min(4, Math.max(1, Math.ceil(t * 4)));
const mix = [0, 28, 48, 72, 100][bucket];
return `color-mix(in oklch, ${accent} ${mix}%, var(--color-secondary))`;
};
const gap = Math.max(2, Math.round(cellSize * 0.22));
const radius = Math.max(2, Math.round(cellSize * 0.28));
const hasData = days.length > 0;
return (
<div
ref={ref}
data-slot="streak-calendar"
className={cn(
"w-full max-w-md rounded-xl bg-card p-5 shadow-border",
className,
)}
{...props}
>
<style href="paragon-streak-calendar" precedence="paragon">{`
@keyframes streak-cell-in {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
[data-streak-cell] { animation: none !important; opacity: 1 !important; transform: none !important; }
}
`}</style>
{/* Header */}
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<span
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg transition-colors duration-(--duration-base) ease-(--ease-out)",
currentStreak > 0
? "bg-warning/15 text-warning"
: "bg-secondary text-muted-foreground",
)}
>
<Flame className="size-4.5" />
</span>
<div className="min-w-0">
<p className="text-lg leading-none font-semibold tabular-nums">
{currentStreak}
<span className="ml-1 text-sm font-normal text-muted-foreground">
day streak
</span>
</p>
<p className="mt-1 truncate text-xs text-muted-foreground tabular-nums">
Longest {longestStreak} · {activeDays}/{days.length} active
</p>
</div>
</div>
{/* Intensity legend */}
<div className="flex shrink-0 items-center gap-1.5 text-[11px] text-muted-foreground">
<span>Less</span>
<span className="flex gap-0.5">
{[0, 1, 2, 3, 4].map((b) => (
<span
key={b}
aria-hidden
className="size-2.5 rounded-[3px]"
style={{
background:
b === 0
? "var(--color-secondary)"
: `color-mix(in oklch, ${accent} ${[0, 28, 48, 72, 100][b]}%, var(--color-secondary))`,
}}
/>
))}
</span>
<span>More</span>
</div>
</div>
{hasData ? (
<div className="flex gap-1.5">
{/* Weekday rail */}
{weekdays && (
<div
aria-hidden
className="flex flex-col justify-between pr-0.5 text-[10px] text-muted-foreground"
style={{ gap: `${gap}px`, height: 7 * cellSize + 6 * gap }}
>
{weekdays.map((w, i) => (
<span
key={w}
className="flex items-center leading-none tabular-nums"
style={{ height: cellSize }}
>
{/* Show every other label to avoid crowding at small sizes. */}
{i % 2 === 1 ? w : ""}
</span>
))}
</div>
)}
{/* Heatmap grid, column-major */}
<div
role="group"
aria-label={`Activity heatmap: ${activeDays} of ${days.length} days active, current streak ${currentStreak} days`}
className="grid grid-flow-col"
style={{
gridTemplateRows: `repeat(7, ${cellSize}px)`,
gridTemplateColumns: `repeat(${columns}, ${cellSize}px)`,
gap: `${gap}px`,
}}
>
{days.map((day, i) => {
const slot = startOffset + i;
const row = slot % 7;
const col = Math.floor(slot / 7);
const delay = animate ? (col * 3 + row) * 12 : 0;
return (
<StreakCell
key={i}
day={day}
unit={unit}
color={bucketColor(day.value)}
cellSize={cellSize}
radius={radius}
gridRow={row + 1}
gridColumn={col + 1}
animate={animate}
shown={shown}
delay={delay}
uid={`${uid}-${i}`}
tabbable={i === focusIdx}
onKeyDown={(event) => handleKeyDown(event, i)}
onFocusCell={() => setFocusIdx(i)}
buttonRef={(el) => {
if (el) cellRefs.current.set(i, el);
else cellRefs.current.delete(i);
}}
/>
);
})}
</div>
</div>
) : (
<div className="flex h-24 items-center justify-center rounded-lg bg-secondary/40 text-xs text-muted-foreground">
No activity recorded yet.
</div>
)}
</div>
);
}
interface StreakCellProps {
day: StreakDay;
unit: string;
color: string;
cellSize: number;
radius: number;
gridRow: number;
gridColumn: number;
animate: boolean;
shown: boolean;
delay: number;
uid: string;
tabbable: boolean;
onKeyDown: (event: React.KeyboardEvent) => void;
onFocusCell: () => void;
buttonRef: (el: HTMLButtonElement | null) => void;
}
function StreakCell({
day,
unit,
color,
cellSize,
radius,
gridRow,
gridColumn,
animate,
shown,
delay,
uid,
tabbable,
onKeyDown,
onFocusCell,
buttonRef,
}: StreakCellProps) {
const [open, setOpen] = React.useState(false);
const active = day.value > 0;
return (
<div
style={{ gridRow, gridColumn }}
className="relative"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<button
type="button"
ref={buttonRef}
tabIndex={tabbable ? 0 : -1}
aria-describedby={open ? uid : undefined}
aria-label={`${day.label}: ${day.value} ${unit}`}
onFocus={() => {
setOpen(true);
onFocusCell();
}}
onBlur={() => setOpen(false)}
onKeyDown={onKeyDown}
data-streak-cell
className={cn(
"block outline-none transition-[filter] duration-150 ease-out",
"hover:brightness-110 focus-visible:brightness-110",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-card",
day.today && "ring-1 ring-primary ring-offset-1 ring-offset-card",
)}
style={{
width: cellSize,
height: cellSize,
borderRadius: radius,
background: color,
...(animate && shown && active
? { animation: `streak-cell-in 220ms var(--ease-out) ${delay}ms both` }
: animate && !shown && active
? { opacity: 0 }
: undefined),
}}
/>
{/* Tooltip — instant on purpose: heatmaps are scanned cell to cell. */}
<div
id={uid}
role="tooltip"
className={cn(
"pointer-events-none absolute bottom-[calc(100%+6px)] left-1/2 z-20 -translate-x-1/2",
"rounded-md bg-primary px-2.5 py-1.5 text-xs whitespace-nowrap text-primary-foreground shadow-overlay",
"origin-bottom transition-[opacity,transform] duration-150 ease-out",
open ? "opacity-100" : "translate-y-1 scale-95 opacity-0",
)}
>
<span className="font-medium tabular-nums">{day.value}</span>{" "}
<span className="text-primary-foreground/80">{unit}</span>
<span className="mx-1 text-primary-foreground/40">·</span>
<span className="tabular-nums text-primary-foreground/80">{day.label}</span>
<span
aria-hidden
className="absolute top-full left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rotate-45 bg-primary"
/>
</div>
</div>
);
}