A calendar heatmap of the kind used for contribution graphs and habit trackers. Each column is a week and each square a day, coloured in five steps from empty to busy. On load the squares pop in as a wave from the top left corner, and the last square, today, keeps a soft pulse. Pointing at, tapping or arrowing to a square outlines it and prints its date and count in a line under the grid. Three totals sit above. The grid is fluid: squares shrink with the card, so all 26 weeks stay visible on a phone.
// Adapted from Animata (https://animata.design/docs/graphs/commit-graph)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useMemo, useState, type KeyboardEvent } from "react";
import { Coffee, Flame, TrendingUp } from "lucide-react";
const WEEKS = 26;
const UNIT = "cups";
// Monday 31 March 2025. The grid runs for WEEKS full weeks from here.
const START = Date.UTC(2025, 2, 31);
// Lightest to darkest: nothing, then four steps of activity.
const COLORS = ["#efe9e1", "#fed7aa", "#fdba74", "#f97316", "#c2410c"];
const THRESHOLDS = [1, 70, 105, 140]; // a day needs at least this many to reach levels 1 to 4
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
function seeded(n: number) {
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
}
/** Made-up but steady sample data: busier at weekends, growing over the half year. Swap in your own counts. */
function sampleCounts() {
const weekday = [58, 64, 72, 80, 104, 150, 118];
return Array.from({ length: WEEKS * 7 }, (_, i) => {
const day = i % 7;
const week = Math.floor(i / 7);
if (day === 0 && seeded(i + 9) > 0.72) return 0; // closed on the odd Monday
return Math.round(weekday[day] * (0.72 + week * 0.014) * (0.7 + seeded(i) * 0.6));
});
}
const levelOf = (count: number) => THRESHOLDS.filter((t) => count >= t).length;
export default function ActivityHeatmapCalendar() {
const counts = useMemo(sampleCounts, []);
const last = counts.length - 1;
const [selected, setSelected] = useState(last);
const dateOf = (i: number) => new Date(START + i * 86400000);
const describe = (i: number) => {
const d = dateOf(i);
return `${DAYS[i % 7]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`;
};
const total = counts.reduce((a, b) => a + b, 0);
const best = counts.indexOf(Math.max(...counts));
let streak = 0;
for (let i = last; i >= 0 && counts[i] > 0; i--) streak++;
// A month name sits above the first week that starts in that month.
const monthLabels = Array.from({ length: WEEKS }, (_, w) => {
const month = dateOf(w * 7).getUTCMonth();
// The very first column only gets a name if the next week is still in the same month, so two names never collide.
if (w === 0) return dateOf(7).getUTCMonth() === month ? MONTHS[month] : "";
return dateOf((w - 1) * 7).getUTCMonth() !== month ? MONTHS[month] : "";
});
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const move = { ArrowUp: -1, ArrowDown: 1, ArrowLeft: -7, ArrowRight: 7 }[event.key];
if (move === undefined) return;
event.preventDefault();
setSelected((i) => Math.max(0, Math.min(last, i + move)));
};
const columns = { gridTemplateColumns: `28px repeat(${WEEKS}, minmax(0, 1fr))` };
return (
<section className="bg-orange-50 px-4 py-14 sm:px-6">
<style>{`
@keyframes activity-heatmap-calendar-pop { from { opacity: 0; transform: scale(0.2) } to { opacity: 1; transform: scale(1) } }
@keyframes activity-heatmap-calendar-today { 0%, 100% { box-shadow: 0 0 0 0 rgba(194, 65, 12, 0.55) } 60% { box-shadow: 0 0 0 6px rgba(194, 65, 12, 0) } }
.activity-heatmap-calendar-cell { animation: activity-heatmap-calendar-pop 460ms cubic-bezier(0.34, 1.56, 0.64, 1) both; }
.activity-heatmap-calendar-today { animation: activity-heatmap-calendar-pop 460ms cubic-bezier(0.34, 1.56, 0.64, 1) both, activity-heatmap-calendar-today 2.2s ease-out 1.4s infinite; }
@media (prefers-reduced-motion: reduce) {
.activity-heatmap-calendar-cell, .activity-heatmap-calendar-today { animation: none; }
}
`}</style>
<div className="mx-auto max-w-4xl rounded-3xl bg-white p-4 shadow-sm ring-1 ring-orange-100 sm:p-8">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-orange-700">
<Coffee aria-hidden="true" className="h-4 w-4" />
Ember Coffee, Dock Street
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-stone-900 sm:text-3xl">Cups sold, day by day</h2>
</div>
<p className="text-sm text-stone-600">31 Mar to 28 Sep</p>
</div>
<dl className="mt-6 grid grid-cols-3 gap-3">
{[
{ label: "Cups in six months", value: total.toLocaleString("en-US"), icon: TrendingUp },
{ label: "Best day", value: `${counts[best]}`, icon: Coffee },
{ label: "Days open in a row", value: `${streak}`, icon: Flame },
].map((stat) => (
<div key={stat.label} className="rounded-2xl bg-orange-50 p-3 sm:p-4">
<dt className="flex items-center gap-1.5 text-xs font-medium text-stone-600">
<stat.icon aria-hidden="true" className="hidden h-3.5 w-3.5 text-orange-700 sm:block" />
{stat.label}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums text-stone-900 sm:text-2xl">{stat.value}</dd>
</div>
))}
</dl>
<div
role="group"
tabIndex={0}
aria-label={`Heatmap of ${UNIT} sold per day over ${WEEKS} weeks. Use the arrow keys to move between days.`}
onKeyDown={onKeyDown}
onPointerLeave={() => setSelected(last)}
className="mt-8 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-700 focus-visible:ring-offset-4"
>
<div aria-hidden="true" className="grid gap-[2px] text-[10px] font-medium text-stone-500 sm:gap-1 sm:text-xs" style={columns}>
<span />
{monthLabels.map((label, w) => (
<span key={w} className="h-5 overflow-visible whitespace-nowrap">
{label}
</span>
))}
</div>
<div className="grid grid-flow-col grid-rows-7 gap-[2px] sm:gap-1" style={columns}>
{DAYS.map((day, d) => (
<span key={day} aria-hidden="true" className="flex items-center text-[10px] font-medium leading-none text-stone-500 sm:text-xs">
{d % 2 === 0 ? day.slice(0, 3) : ""}
</span>
))}
{counts.map((count, i) => {
const week = Math.floor(i / 7);
const day = i % 7;
return (
<span
key={i}
onPointerEnter={() => setSelected(i)}
onPointerDown={() => setSelected(i)}
className={`aspect-square w-full rounded-[3px] sm:rounded-md ${i === last ? "activity-heatmap-calendar-today" : "activity-heatmap-calendar-cell"} ${
i === selected ? "outline outline-2 outline-offset-1 outline-stone-900" : ""
}`}
style={{ background: COLORS[levelOf(count)], animationDelay: `${week * 24 + day * 14}ms` }}
/>
);
})}
</div>
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<p aria-live="polite" className="text-sm text-stone-700">
<span className="font-semibold text-stone-900">{describe(selected)}</span>
{": "}
{counts[selected] === 0 ? "closed" : `${counts[selected]} ${UNIT}`}
{selected === last ? " (today)" : ""}
</p>
<p className="flex items-center gap-1.5 text-xs text-stone-600">
Fewer
{COLORS.map((color) => (
<span key={color} aria-hidden="true" className="h-3.5 w-3.5 rounded-[3px]" style={{ background: color }} />
))}
More
</p>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/activity-heatmap-calendar-with-wave-reveal-and-day-readout.json
It is a CSS grid with seven rows that fills column by column (`grid-flow-col grid-rows-7`). The first column holds the weekday names and each later column is one week, so the data array can simply run day after day.
Using `Date.UTC` and the `getUTC` methods gives the same dates on the server and in every visitor's time zone. That avoids a mismatch between the server-rendered page and the browser.
Set `WEEKS` to `53` and supply 371 values. On phones a full year makes very small squares, so wrap the grid in a container with `overflow-x-auto` and give the grid a `min-w-[640px]`.
Yes. The simplest route is to act on the selected day: add a link next to the readout that points to the page for `selected`. Making 182 squares into separate links would give keyboard users 182 tab stops.
The sample numbers come from a small seeded function instead of `Math.random`, so the server and browser draw the same picture. Your real data replaces it.
Adapted from Commit Graph in Animata, used under its open-source licence. Licences and credits
Related: Stat Tiles with Sparkline Trends, Activity Rings Card with Day Switcher, Stats Section with Radial Progress Rings.
More dashboard or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.