A daily streak heatmap grid with today emphasized and a milestone badge at streak marks.
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. Cells are
* focusable and carry a date+value tooltip. The current and longest 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);
// 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.55); }
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 items-center gap-2.5">
<span
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-lg transition-colors duration-200",
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 font-semibold leading-none tabular-nums">
{currentStreak}
<span className="ml-1 text-sm font-normal text-muted-foreground">
day streak
</span>
</p>
<p className="mt-1 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="grid"
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}`}
/>
);
})}
</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;
}
function StreakCell({
day,
unit,
color,
cellSize,
radius,
gridRow,
gridColumn,
animate,
shown,
delay,
uid,
}: 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"
role="gridcell"
aria-describedby={open ? uid : undefined}
aria-label={`${day.label}: ${day.value} ${unit}`}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
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 */}
<div
id={uid}
role="tooltip"
className={cn(
"pointer-events-none absolute bottom-[calc(100%+6px)] left-1/2 z-20 -translate-x-1/2",
"whitespace-nowrap rounded-md bg-primary px-2.5 py-1.5 text-xs 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>
);
}