Activity Heatmap Calendar with Wave Reveal and Day Readout

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.

Code

// 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

When to use it

How to customize it

Accessibility

Questions

How is the grid laid out?

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.

Why are the dates worked out in UTC?

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.

How do I show a full year?

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]`.

Can each square link to that day's detail?

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.

Why does the data look the same on every load?

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.

More in Dashboard

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.

Product

Who It's For

Features

Resources

Company

View Sitemap