Feedback & Data
ActivityPulse
A calendar heatmap. Cells pop in as a wave from the left, the newest one keeps pulsing, and pointer, touch or arrow keys read any day.
Download
- React
- Tailwind CSS v4
- motion
components/ui/activity-pulse.tsx
"use client";
import { useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
type ActivityPulseProps = {
/** One entry per day, oldest first, top to bottom then left to right. */
data: { label: string; value: number }[];
/** Title. Also the name for screen readers. */
label: string;
/** Singular name of what is counted, like "commit". */
unit?: string;
/** Cells in each column. */
rows?: number;
/** Pulse on the newest cell. */
live?: boolean;
/** Cell colour. */
accent?: string;
/** Wrapper look (text colour, width). */
className?: string;
};
const SHADES = [0, 30, 50, 75, 100];
const shade = (level: number) => (level ? `color-mix(in srgb, var(--ap) ${SHADES[level]}%, transparent)` : "rgb(255 255 255 / 0.06)");
/* ActivityPulse: a calendar heatmap. Cells pop in as a wave from the left, the newest one keeps pulsing, and pointer, touch or arrow keys read any day. */
export function ActivityPulse({ data, label, unit = "action", rows = 7, live = true, accent = "#a78bfa", className = "text-white" }: ActivityPulseProps) {
const reduce = useReducedMotion();
const box = useRef<HTMLElement>(null);
const seen = useInView(box, { once: true, amount: 0.4 });
const [active, setActive] = useState<number | null>(null);
const last = data.length - 1;
const columns = Math.ceil(data.length / rows);
const total = data.reduce((sum, day) => sum + day.value, 0);
const says = (value: number) => `${value.toLocaleString("en-US")} ${unit}${value === 1 ? "" : "s"}`;
// Kept out of the render path so moving the pointer never touches every cell.
const cells = useMemo(() => {
const max = Math.max(1, ...data.map((day) => day.value));
return data.map((day, i) => (
<span key={day.label} className="relative aspect-square p-[1.5px]">
<motion.span
className="block size-full rounded-[3px]"
style={{ backgroundColor: shade(Math.ceil((day.value / max) * 4)) }}
initial={reduce ? false : { scale: 0, opacity: 0 }}
animate={seen ? { scale: 1, opacity: 1 } : undefined}
transition={{ type: "spring", stiffness: 320, damping: 20, delay: Math.floor(i / rows) * 0.022 }}
/>
{live && i === data.length - 1 && !reduce && (
<motion.span
className="absolute inset-[1.5px] rounded-[3px] bg-(--ap)"
animate={{ scale: [1, 2], opacity: [0.7, 0] }}
transition={{ duration: 1.6, ease: "easeOut", repeat: Infinity }}
/>
)}
</span>
));
}, [data, live, reduce, rows, seen]);
if (!data.length) return null;
const current = active ?? last;
const day = data[current];
const column = Math.floor(current / rows);
const row = current % rows;
const pick = (e: PointerEvent<HTMLDivElement>) => {
const grid = e.currentTarget.getBoundingClientRect();
const at = (spot: number, size: number, count: number) => Math.min(count - 1, Math.max(0, Math.floor((spot / size) * count)));
setActive(Math.min(last, at(e.clientX - grid.left, grid.width, columns) * rows + at(e.clientY - grid.top, grid.height, rows)));
};
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
const moves: Record<string, number> = { ArrowRight: current + rows, ArrowLeft: current - rows, ArrowDown: current + 1, ArrowUp: current - 1, Home: 0, End: last };
if (!(e.key in moves)) return;
e.preventDefault();
setActive(Math.min(last, Math.max(0, moves[e.key])));
};
return (
<figure ref={box} style={{ "--ap": accent } as CSSProperties} className={`w-full ${className}`}>
<figcaption className="mb-3 text-sm font-medium">{label}</figcaption>
<div
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={last}
aria-valuenow={current}
aria-valuetext={`${day.label}: ${says(day.value)}`}
onPointerMove={pick}
onPointerDown={pick}
onPointerLeave={(e) => e.pointerType === "mouse" && setActive(null)}
onFocus={() => setActive((a) => a ?? last)}
onBlur={() => setActive(null)}
onKeyDown={onKey}
className="relative touch-pan-y rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
<div aria-hidden className="grid grid-flow-col" style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${rows}, auto)` }}>
{cells}
</div>
{active !== null && (
<div
aria-hidden
style={{ transform: `translate(${(column / columns) * 100}%, ${(row / rows) * 100}%)` }}
className="pointer-events-none absolute inset-0 transition-transform duration-100 ease-out motion-reduce:transition-none"
>
<span style={{ width: `${100 / columns}%`, height: `${100 / rows}%` }} className="absolute top-0 left-0 rounded-[4px] ring-2 ring-white" />
<div
style={{
top: row < 3 ? `${100 / rows}%` : 0,
transform: `translate(${column / columns < 0.25 ? 0 : column / columns > 0.75 ? -100 : -50}%, ${row < 3 ? "6px" : "calc(-100% - 6px)"})`,
}}
className="absolute left-0 rounded-lg border border-white/10 bg-[#17151f] px-2.5 py-1.5 text-xs whitespace-nowrap text-white shadow-lg"
>
<span className="font-medium tabular-nums">{says(day.value)}</span> <span className="text-white/60">{day.label}</span>
</div>
</div>
)}
</div>
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-xs text-white/50">
<span>{`${says(total)} in total`}</span>
<span aria-hidden className="flex items-center gap-1">
Less
{SHADES.map((_, level) => (
<span key={level} style={{ backgroundColor: shade(level) }} className="size-2.5 rounded-[2px]" />
))}
More
</span>
</div>
</figure>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/activity-pulse.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/activity-pulse-demo.tsx
"use client"; import { ActivityPulse } from "@/components/ui/activity-pulse"; const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; // Sample data: 26 weeks of made-up commits, quiet at weekends. const COMMITS = Array.from({ length: 182 }, (_, i) => { const weekend = i % 7 > 4 ? 0.3 : 1; return { label: `${DAYS[i % 7]}, week ${Math.floor(i / 7) + 1}`, value: Math.max(0, Math.round((1.2 + Math.sin(i * 0.37) + Math.sin(i * 0.11) + Math.sin(i * 1.7)) * 3 * weekend)), }; }); export default function ActivityPulseExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-5"> <ActivityPulse label="Commits, last 26 weeks" unit="commit" data={COMMITS} className="max-w-xl text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| data | { label, value }[] | — | One entry per day, oldest first, top to bottom then left to right. Labels must be different. |
| label | string | — | Title. Also the name for screen readers. |
| unit | string | "action" | Singular name of what is counted, like "commit". |
| rows | number | 7 | Cells in each column. |
| live | boolean | true | Pulse on the newest cell. |
| accent | string | "#a78bfa" | Cell colour. |
| className | string | "text-white" | Wrapper look (text colour, width). |