Charts

Heatmap Calendar

A GitHub-style contribution grid with a 5-step single-hue scale, row-staggered fade-in, month labels, and per-cell tooltips.

Install

npx shadcn@latest add @paragon/heatmap-calendar

heatmap-calendar.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

const GAP = 3;
const MONTH_ROW = 18; // vertical space reserved for month labels
const LEVELS = 5; // 0 = empty, 1..4 = intensity buckets
const DAY_LABEL_ROWS = [1, 3, 5] as const; // Mon / Wed / Fri
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTH_NAMES = [
  "Jan", "Feb", "Mar", "Apr", "May", "Jun",
  "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];

function parseDay(iso: string) {
  return new Date(`${iso}T00:00:00Z`);
}
function addDays(date: Date, days: number) {
  return new Date(date.getTime() + days * 86400000);
}
function isoDay(date: Date) {
  return date.toISOString().slice(0, 10);
}

/**
 * Intensity ramp: mix the accent color into the surface in five steps.
 * Level 0 is a faint neutral "empty" cell; 1..4 climb toward the full accent.
 */
function levelFill(level: number, color: string) {
  if (level <= 0) return "color-mix(in oklab, var(--muted-foreground) 12%, transparent)";
  const pct = [22, 44, 70, 100][Math.min(3, level - 1)];
  return `color-mix(in oklab, ${color} ${pct}%, var(--card))`;
}

export interface HeatmapDatum {
  /** ISO date, e.g. "2026-03-14". */
  date: string;
  count: number;
}

export interface HeatmapCalendarProps
  extends Omit<React.ComponentProps<"div">, "onSelect"> {
  data: HeatmapDatum[];
  /** Last day shown. Defaults to the latest date in `data`. */
  endDate?: string;
  /** Number of week columns to render (counting back from endDate). */
  weeks?: number;
  /** Cell edge length in px. Gap scales with it. */
  cellSize?: number;
  /** Accent color for the intensity ramp. */
  color?: string;
  /** Weekday labels (Mon/Wed/Fri) down the left edge. */
  showWeekdays?: boolean;
  /** Legend + total footer. */
  showLegend?: boolean;
  /** Tooltip line for a cell, e.g. `(3) => "3 deploys"`. */
  countLabel?: (count: number) => string;
  /** Noun used in the total, e.g. "deploys". */
  totalNoun?: string;
  /** Accessible description of the chart. */
  label?: string;
  /** Animation speed multiplier for the enter stagger. */
  speed?: number;
  /** Renders the final state immediately, no fade-in. */
  static?: boolean;
}

/**
 * A GitHub-contributions-style calendar. The grid is computed from the date
 * sequence: week columns count back from `endDate`, weekday rows are Sun→Sat,
 * and partial first/last weeks are clipped to the range. Month labels sit
 * exactly above the first week that opens each month, weekday labels align to
 * their rows, and colors bucket the values into five accent-mixed steps.
 *
 * Cells fade + scale in with a diagonal stagger on first view (once), each is
 * keyboard-focusable, and hovering or focusing anchors a date-and-value
 * tooltip. Reduced motion renders the final state immediately.
 */
export function HeatmapCalendar({
  data,
  endDate,
  weeks = 26,
  cellSize = 12,
  color = "var(--primary)",
  showWeekdays = true,
  showLegend = true,
  countLabel = (count) =>
    `${count === 0 ? "No" : count.toLocaleString("en-US")} ${
      count === 1 ? "contribution" : "contributions"
    }`,
  totalNoun = "contributions",
  label,
  speed = 1,
  static: isStatic = false,
  className,
  ...props
}: HeatmapCalendarProps) {
  const containerRef = React.useRef<HTMLDivElement>(null);
  const reducedMotion = useReducedMotion();
  const inView = useInView(containerRef, {
    once: true,
    margin: "0px 0px -24px 0px",
  });
  const [tip, setTip] = React.useState<{
    x: number;
    y: number;
    iso: string;
    text: string;
    visible: boolean;
  } | null>(null);

  const animate = !isStatic && !reducedMotion;
  const drawn = !animate || inView;

  const CELL = cellSize;
  const PITCH = CELL + GAP;
  // Left gutter: room for weekday labels; else a small breathing edge.
  const GUTTER = showWeekdays ? Math.round(CELL * 2.4) + 8 : 2;

  const counts = React.useMemo(() => {
    const map = new Map<string, number>();
    for (const d of data) map.set(d.date, (map.get(d.date) ?? 0) + d.count);
    return map;
  }, [data]);

  // Quantile thresholds over the non-zero values — buckets 1..4.
  const thresholds = React.useMemo(() => {
    const nonzero = data
      .map((d) => d.count)
      .filter((c) => c > 0)
      .sort((a, b) => a - b);
    if (nonzero.length === 0) return [1, 1, 1, 1];
    const q = (p: number) =>
      nonzero[Math.min(nonzero.length - 1, Math.floor(p * nonzero.length))];
    return [q(0.25), q(0.5), q(0.75), q(0.9)];
  }, [data]);

  const levelFor = React.useCallback(
    (count: number) => {
      if (count <= 0) return 0;
      let lvl = 1;
      for (let i = 0; i < thresholds.length; i++) {
        if (count >= thresholds[i]) lvl = i + 1;
      }
      return Math.min(LEVELS - 1, lvl);
    },
    [thresholds],
  );

  const end = React.useMemo(() => {
    if (endDate) return parseDay(endDate);
    if (data.length > 0) {
      return parseDay(
        data.reduce((latest, d) => (d.date > latest ? d.date : latest), data[0].date),
      );
    }
    return parseDay("2026-01-01");
  }, [endDate, data]);

  // Grid geometry. Last column ends on the week containing `end`; count back.
  const firstOfLastColumn = addDays(end, -end.getUTCDay());
  const start = addDays(firstOfLastColumn, -(weeks - 1) * 7);

  const gridW = weeks * PITCH - GAP;
  const gridH = 7 * PITCH - GAP;
  const svgWidth = GUTTER + gridW;
  const svgHeight = MONTH_ROW + gridH;

  const dateFormat = React.useMemo(
    () =>
      new Intl.DateTimeFormat("en-US", {
        weekday: "short",
        month: "short",
        day: "numeric",
        year: "numeric",
        timeZone: "UTC",
      }),
    [],
  );

  // Build the visible cells + running total + month boundaries.
  const { cells, total, monthLabels } = React.useMemo(() => {
    const out: {
      iso: string;
      date: Date;
      x: number;
      y: number;
      week: number;
      row: number;
      count: number;
      level: number;
    }[] = [];
    let sum = 0;
    const months: { week: number; text: string }[] = [];
    let lastLabelWeek = -3;

    for (let w = 0; w < weeks; w++) {
      const columnFirst = addDays(start, w * 7);
      // Month label above the first week whose Sunday opens a new month —
      // and where a full label won't run off the right edge.
      const prevColumnFirst = addDays(start, (w - 1) * 7);
      const monthChanged =
        w === 0 || columnFirst.getUTCMonth() !== prevColumnFirst.getUTCMonth();
      if (monthChanged && w - lastLabelWeek >= 3 && w <= weeks - 3) {
        months.push({ week: w, text: MONTH_NAMES[columnFirst.getUTCMonth()] });
        lastLabelWeek = w;
      }

      for (let row = 0; row < 7; row++) {
        const date = addDays(start, w * 7 + row);
        if (date < start || date > end) continue; // clip partial edges
        const iso = isoDay(date);
        const count = counts.get(iso) ?? 0;
        sum += count;
        out.push({
          iso,
          date,
          x: GUTTER + w * PITCH,
          y: MONTH_ROW + row * PITCH,
          week: w,
          row,
          count,
          level: levelFor(count),
        });
      }
    }
    return { cells: out, total: sum, monthLabels: months };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [weeks, start.getTime(), end.getTime(), counts, levelFor, GUTTER, PITCH]);

  const hasData = cells.length > 0 && data.some((d) => d.count > 0);
  const enterStep = Math.max(60, 260 / speed); // total spread of the stagger

  return (
    <div
      ref={containerRef}
      data-slot="heatmap-calendar"
      className={cn("relative w-fit", className)}
      {...props}
    >
      {!hasData ? (
        <div
          className="flex h-24 items-center justify-center rounded-lg px-6 text-sm text-muted-foreground shadow-border"
          style={{ minWidth: svgWidth }}
        >
          No data
        </div>
      ) : (
        <svg
          role="img"
          aria-label={label ?? `Activity calendar, ${weeks} weeks`}
          width={svgWidth}
          height={svgHeight}
          viewBox={`0 0 ${svgWidth} ${svgHeight}`}
          className="block max-w-full"
          onPointerLeave={() => setTip((t) => (t ? { ...t, visible: false } : t))}
        >
          {/* Month labels — anchored to the start column of each month. */}
          {monthLabels.map(({ week, text }) => (
            <text
              key={`m-${week}`}
              x={GUTTER + week * PITCH}
              y={11}
              fontSize={10}
              className="fill-muted-foreground select-none"
            >
              {text}
            </text>
          ))}

          {/* Weekday labels — vertically centered on their row. */}
          {showWeekdays &&
            DAY_LABEL_ROWS.map((row) => (
              <text
                key={`d-${row}`}
                x={GUTTER - 8}
                y={MONTH_ROW + row * PITCH + CELL / 2}
                textAnchor="end"
                dominantBaseline="central"
                fontSize={10}
                className="fill-muted-foreground select-none"
              >
                {DAY_NAMES[row]}
              </text>
            ))}

          {cells.map((cell, i) => {
            const hovered = tip?.visible && tip.iso === cell.iso;
            const delay = animate
              ? ((cell.week + cell.row) /
                  (weeks + 7)) *
                enterStep
              : 0;
            return (
              <rect
                key={cell.iso}
                x={cell.x}
                y={cell.y}
                width={CELL}
                height={CELL}
                rx={Math.max(2, Math.round(CELL * 0.22))}
                fill={levelFill(cell.level, color)}
                tabIndex={0}
                role="img"
                aria-label={`${countLabel(cell.count)}, ${dateFormat.format(cell.date)}`}
                className="cursor-default outline-none [transform-box:fill-box] [transform-origin:center] focus-visible:[stroke:var(--ring)] focus-visible:[stroke-width:2px]"
                stroke={hovered ? "var(--foreground)" : undefined}
                strokeOpacity={hovered ? 0.5 : undefined}
                onPointerEnter={() =>
                  setTip({
                    x: cell.x + CELL / 2,
                    y: cell.y,
                    iso: cell.iso,
                    text: `${countLabel(cell.count)} · ${dateFormat.format(cell.date)}`,
                    visible: true,
                  })
                }
                onFocus={() =>
                  setTip({
                    x: cell.x + CELL / 2,
                    y: cell.y,
                    iso: cell.iso,
                    text: `${countLabel(cell.count)} · ${dateFormat.format(cell.date)}`,
                    visible: true,
                  })
                }
                onBlur={() => setTip((t) => (t ? { ...t, visible: false } : t))}
                style={{
                  opacity: drawn ? 1 : 0,
                  transform: drawn ? "scale(1)" : "scale(0.9)",
                  transition: animate
                    ? `opacity 220ms var(--ease-out) ${delay}ms, transform 220ms var(--ease-out) ${delay}ms, stroke 120ms var(--ease-out)`
                    : "stroke 120ms var(--ease-out)",
                  willChange: drawn ? undefined : "opacity, transform",
                }}
              />
            );
          })}
        </svg>
      )}

      {/* Tooltip — anchored above the cell, tokened fade on appear. */}
      {tip && (
        <div
          aria-hidden
          className="pointer-events-none absolute top-0 left-0 z-10 rounded-md bg-popover px-2 py-1 text-xs whitespace-nowrap text-popover-foreground shadow-overlay transition-opacity"
          style={{
            opacity: tip.visible ? 1 : 0,
            transitionDuration: "var(--duration-fast)",
            transitionTimingFunction: "var(--ease-out)",
            transform: `translate(${tip.x}px, ${tip.y}px) translate(-50%, calc(-100% - 6px))`,
          }}
        >
          {tip.text}
        </div>
      )}

      {showLegend && hasData && (
        <div className="mt-3 flex items-center justify-between gap-4 text-[11px] text-muted-foreground">
          <span className="tabular-nums">
            <span className="font-medium text-foreground">
              {total.toLocaleString("en-US")}
            </span>{" "}
            {totalNoun}
          </span>
          <span className="flex items-center gap-1.5">
            Less
            <span className="flex gap-[3px]" aria-hidden>
              {Array.from({ length: LEVELS }, (_, level) => (
                <span
                  key={level}
                  className="rounded-[2px]"
                  style={{
                    width: 10,
                    height: 10,
                    background: levelFill(level, color),
                  }}
                />
              ))}
            </span>
            More
          </span>
        </div>
      )}
    </div>
  );
}