# Calendar Heatmap Year — React Calendar Component

> A contribution-style year heatmap: weeks ripple in column by column, and one tooltip springs from cell to cell under the pointer or the arrow keys.

- Category: [Calendars & Dates](https://pixelpapa.com/components/calendars.md)
- Tags: calendar, heatmap, year, activity, tooltip
- Dependencies: motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/calendars/calendar-heatmap-year

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/calendar-heatmap-year.json"
```

The file is written to `components/pixelpapa/calendar-heatmap-year.tsx`. To install manually, run `npm install motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { motion, useReducedMotion } from "motion/react";
import { useRef, useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
const iso = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;

const CELL = 9;
const GAP = 3;
const PITCH = CELL + GAP;
const levels = ["bg-muted", "bg-primary/25", "bg-primary/50", "bg-primary/75", "bg-primary"];

type YearHeatmapProps = {
  /** Counts keyed by ISO date ("2026-03-14"). Missing days count as zero. */
  data: Record<string, number>;
  /** Last day shown; the grid covers the 53 weeks ending here. */
  end?: Date;
  /** Word used in the tooltip, e.g. "commit". */
  unit?: string;
  className?: string;
};

const DEMO_END = new Date(2026, 2, 14);

export function YearHeatmap({ data, end = DEMO_END, unit = "contribution", className }: YearHeatmapProps) {
  const [active, setActive] = useState<number | null>(null);
  const [cursor, setCursor] = useState(0); // Roving tab stop for keyboard users.
  const cells = useRef<(HTMLButtonElement | null)[]>([]);
  const reduce = useReducedMotion();

  // Start on the Sunday 52 weeks before the week that contains `end`.
  const start = addDays(end, -end.getDay() - 52 * 7);
  const total = 52 * 7 + end.getDay() + 1;
  const days = Array.from({ length: total }, (_, i) => {
    const date = addDays(start, i);
    return { date, count: data[iso(date)] ?? 0 };
  });
  const peak = Math.max(1, ...days.map((d) => d.count));
  const level = (n: number) => (n === 0 ? 0 : Math.min(4, Math.ceil((n / peak) * 4)));

  const monthLabels = Array.from({ length: 53 }, (_, w) => {
    const first = addDays(start, w * 7);
    const prev = addDays(first, -7);
    return w === 0 || first.getMonth() !== prev.getMonth() ? MONTHS[first.getMonth()] : null;
  });

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const moves: Record<string, number> = { ArrowUp: -1, ArrowDown: 1, ArrowLeft: -7, ArrowRight: 7, Home: -cursor, End: total - 1 - cursor };
    if (!(e.key in moves)) return;
    e.preventDefault();
    const next = Math.min(total - 1, Math.max(0, cursor + moves[e.key]));
    setCursor(next);
    cells.current[next]?.focus();
  }

  const shown = active !== null ? days[active] : null;

  return (
    <div className={cn("w-fit", className)}>
      <style>{`@keyframes pp-calendar-heatmap-year-in { from { opacity: 0; transform: scale(0.3); } }`}</style>
      <div className="flex gap-2">
        <div aria-hidden className="mt-5 grid text-[10px] leading-none text-muted-foreground" style={{ gridTemplateRows: `repeat(7, ${CELL}px)`, rowGap: GAP }}>
          {["", "Mon", "", "Wed", "", "Fri", ""].map((d, i) => (
            <span key={i} className="flex items-center">
              {d}
            </span>
          ))}
        </div>
        <div>
          <div aria-hidden className="relative h-5 text-[10px] text-muted-foreground">
            {monthLabels.map((m, w) =>
              // Skip a label that would collide with the next one (a partial first month).
              m && !(w === 0 && monthLabels[1]) && !(w === 0 && monthLabels[2]) ? (
                <span key={w} className="absolute top-0" style={{ left: w * PITCH }}>
                  {m}
                </span>
              ) : null,
            )}
          </div>
          <div
            role="grid"
            aria-label={`${unit} activity for the past year`}
            onKeyDown={onKeyDown}
            onPointerLeave={() => setActive(null)}
            className="relative grid grid-flow-col"
            style={{ gridTemplateRows: `repeat(7, ${CELL}px)`, gridAutoColumns: CELL, gap: GAP }}
          >
            {days.map((d, i) => (
              <button
                key={i}
                ref={(el) => {
                  cells.current[i] = el;
                }}
                type="button"
                role="gridcell"
                tabIndex={i === cursor ? 0 : -1}
                aria-label={`${d.count} ${unit}${d.count === 1 ? "" : "s"} on ${MONTHS[d.date.getMonth()]} ${d.date.getDate()}, ${d.date.getFullYear()}`}
                onPointerEnter={() => setActive(i)}
                onFocus={() => {
                  setActive(i);
                  setCursor(i);
                }}
                onBlur={() => setActive(null)}
                className={cn(
                  "cursor-default rounded-[3px] outline-offset-1 transition-[scale] duration-150 hover:scale-125 motion-reduce:animate-none",
                  levels[level(d.count)],
                  active === i && "scale-125 ring-1 ring-foreground/40",
                )}
                style={reduce ? undefined : { animation: `pp-calendar-heatmap-year-in 0.4s ${Math.floor(i / 7) * 12}ms backwards cubic-bezier(0.34, 1.56, 0.64, 1)` }}
              />
            ))}
            {/* A single tooltip that travels: its position is animated, not re-mounted. */}
            <motion.div
              role="tooltip"
              initial={false}
              animate={{
                opacity: shown ? 1 : 0,
                scale: shown ? 1 : 0.9,
                x: active !== null ? Math.floor(active / 7) * PITCH + CELL / 2 : undefined,
                y: active !== null ? (active % 7) * PITCH : undefined,
              }}
              transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 34 }}
              className="pointer-events-none absolute top-0 left-0 z-10"
            >
              <div className="absolute bottom-2 left-0 -translate-x-1/2 rounded-lg bg-foreground px-2.5 py-1.5 text-xs whitespace-nowrap text-background shadow-lg">
                {shown && (
                  <>
                    <span className="font-semibold tabular-nums">
                      {shown.count === 0 ? "No" : shown.count} {unit}
                      {shown.count === 1 ? "" : "s"}
                    </span>
                    <span className="opacity-70">
                      {" "}
                      · {MONTHS[shown.date.getMonth()]} {shown.date.getDate()}
                    </span>
                  </>
                )}
              </div>
            </motion.div>
          </div>
        </div>
      </div>
      <div className="mt-3 flex items-center justify-end gap-1.5 text-[11px] text-muted-foreground" aria-hidden>
        Less
        {levels.map((l) => (
          <span key={l} className={cn("size-2.5 rounded-[3px]", l)} />
        ))}
        More
      </div>
    </div>
  );
}

// Seeded generator (mulberry32) so the demo renders the same data on the server and the client.
function seeded(seed: number) {
  let a = seed;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function buildDemoData() {
  const random = seeded(2026);
  const data: Record<string, number> = {};
  for (let i = 0; i < 372; i++) {
    const date = addDays(DEMO_END, -i);
    const weekend = date.getDay() === 0 || date.getDay() === 6;
    const season = 0.65 + 0.35 * Math.sin((i / 372) * Math.PI * 3); // Busy and quiet stretches.
    const r = random();
    const count = r < (weekend ? 0.6 : 0.16) ? 0 : Math.round(r * r * 14 * season * (weekend ? 0.4 : 1));
    if (count) data[iso(date)] = count;
  }
  return data;
}
const demoData = buildDemoData();

export default function CalendarHeatmapYearDemo() {
  const counts = Object.values(demoData);
  const total = counts.reduce((a, b) => a + b, 0);
  let streak = 0;
  let best = 0;
  for (let i = 371; i >= 0; i--) {
    streak = demoData[iso(addDays(DEMO_END, -i))] ? streak + 1 : 0;
    best = Math.max(best, streak);
  }

  return (
    <div className="rounded-2xl border border-border bg-card p-5 shadow-sm">
      <div className="mb-4 flex items-end justify-between gap-6">
        <div>
          <h3 className="text-lg font-semibold tracking-tight text-card-foreground">
            <span className="tabular-nums">{total.toLocaleString("en-US")}</span> commits in the last year
          </h3>
          <p className="text-sm text-muted-foreground">Mira Okafor · halden/design-system</p>
        </div>
        <dl className="flex gap-5 text-right text-xs text-muted-foreground">
          <div>
            <dt>Active days</dt>
            <dd className="text-base font-semibold text-card-foreground tabular-nums">{counts.length}</dd>
          </div>
          <div>
            <dt>Best streak</dt>
            <dd className="text-base font-semibold text-card-foreground tabular-nums">{best} days</dd>
          </div>
        </dl>
      </div>
      <YearHeatmap data={demoData} unit="commit" />
    </div>
  );
}
```

## AI prompt

```text
Add the "Calendar Heatmap Year" component from PixelPapa UI to this project.

What it is: A contribution-style year heatmap: weeks ripple in column by column, and one tooltip springs from cell to cell under the pointer or the arrow keys.
Category: Calendars & Dates. Depends on: motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/calendar-heatmap-year.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/calendar-heatmap-year.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/calendar-heatmap-year" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More calendars & dates

- [Calendar Event Dots](https://pixelpapa.com/components/calendars/calendar-event-dots.md) (Pro): A month grid with coloured dots for each day's events beside an agenda that re-staggers for the selected day; months slide and arrow keys move the selection.
- [Calendar Month Basic](https://pixelpapa.com/components/calendars/calendar-month-basic.md) (Pro): A single-month calendar where months slide in from the direction you navigate, the selection pill glides between days, and the grid is fully arrow-key driven.
- [Calendar Range Picker](https://pixelpapa.com/components/calendars/calendar-range-picker.md) (Pro): Two months side by side for picking a date range: click a start, and a soft band previews the span under your cursor until the second click locks it in.
- [Calendar Week Strip](https://pixelpapa.com/components/calendars/calendar-week-strip.md): A one-week day strip with a selection pill that slides between days; weeks swipe in from the side you navigate and arrow keys roll over into the next week.
- [Date Picker Popover](https://pixelpapa.com/components/calendars/date-picker-popover.md) (Pro): A date field you can type into or open as a popover with quick presets and a sliding month grid; picking a day springs the popover shut and fills the field.
- [Time Picker Wheel](https://pixelpapa.com/components/calendars/time-picker-wheel.md): Hour, minute and AM/PM drums you scroll, flick or drag; rows curve away like a cylinder, snap into the centre band, and arrow keys step each wheel.
