Web InnoventixFreeCode

Chart Heatmap

Original · free

contribution-style heatmap grid

byWeb InnoventixReact + Tailwind
chartheatmapcharts
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/chart-heatmap.json
chart-heatmap.tsx
"use client";

import { useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

type CalDay = {
  date: Date;
  row: number;
  col: number;
  index: number;
};

type MonthLabel = { col: number; text: string };

type Calendar = {
  days: CalDay[];
  matrix: CalDay[][];
  monthLabels: MonthLabel[];
  start: Date;
  end: Date;
};

type Metric = {
  id: string;
  label: string;
  unit: string;
  seed: number;
  scale: number;
  phase: number;
};

type Stats = {
  total: number;
  activeDays: number;
  longest: number;
  current: number;
  bestIndex: number;
  bestCount: number;
};

type Dataset = { counts: number[]; stats: Stats };

const WEEKS = 53;
const ROWS = 7;
const TOTAL_DAYS = WEEKS * ROWS;

const CELL = 13;
const GAP = 3;
const PITCH = CELL + GAP;
const MONTH_H = 18;
const HEADER_GAP = 4;
const WEEK_COL_W = 30;

const MONTHS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
];

const MONTHS_SHORT = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
];

const WEEKDAYS = [
  "Sunday",
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
];

const WEEKDAY_LABELS: { row: number; text: string }[] = [
  { row: 1, text: "Mon" },
  { row: 3, text: "Wed" },
  { row: 5, text: "Fri" },
];

const METRICS: Metric[] = [
  { id: "commits", label: "Commits", unit: "commits", seed: 0x9e3779b1, scale: 14, phase: 0 },
  { id: "reviews", label: "Reviews", unit: "reviews", seed: 0x1b873593, scale: 8, phase: 1.7 },
  { id: "issues", label: "Issues", unit: "issues", seed: 0x27d4eb2f, scale: 6, phase: 3.4 },
];

const LEVEL_CLASSES = [
  "bg-slate-200/80 dark:bg-slate-800",
  "bg-emerald-200 dark:bg-emerald-900",
  "bg-emerald-300 dark:bg-emerald-700",
  "bg-emerald-400 dark:bg-emerald-600",
  "bg-emerald-500 dark:bg-emerald-400",
];

function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  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 buildCalendar(): Calendar {
  const anchor = new Date(2026, 5, 27);
  const end = new Date(anchor);
  end.setDate(end.getDate() + (6 - end.getDay()));
  const start = new Date(end);
  start.setDate(start.getDate() - (TOTAL_DAYS - 1));

  const days: CalDay[] = [];
  const matrix: CalDay[][] = Array.from({ length: ROWS }, () => []);

  for (let i = 0; i < TOTAL_DAYS; i++) {
    const date = new Date(start);
    date.setDate(start.getDate() + i);
    const row = i % ROWS;
    const col = Math.floor(i / ROWS);
    const day: CalDay = { date, row, col, index: i };
    days.push(day);
    matrix[row][col] = day;
  }

  const monthLabels: MonthLabel[] = [];
  let lastMonth = -1;
  for (let col = 0; col < WEEKS; col++) {
    const month = matrix[0][col].date.getMonth();
    if (month !== lastMonth) {
      monthLabels.push({ col, text: MONTHS_SHORT[month] });
      lastMonth = month;
    }
  }

  return { days, matrix, monthLabels, start, end };
}

function buildDataset(days: CalDay[], metric: Metric): Dataset {
  const rng = mulberry32(metric.seed);
  const counts: number[] = new Array(days.length);

  for (let i = 0; i < days.length; i++) {
    const day = days[i];
    const weekend = day.row === 0 || day.row === 6;
    const roll = rng();
    const size = rng();
    const idleChance = weekend ? 0.5 : 0.16;
    let count = 0;
    if (roll > idleChance) {
      const wave = 0.7 + 0.5 * Math.sin((day.col / WEEKS) * Math.PI * 2 + metric.phase);
      const base = Math.pow(size, 2.1) * metric.scale * (weekend ? 0.55 : 1);
      count = Math.max(0, Math.round(base * wave));
    }
    counts[i] = count;
  }

  let total = 0;
  let activeDays = 0;
  let longest = 0;
  let run = 0;
  let bestIndex = 0;
  let bestCount = -1;

  for (let i = 0; i < counts.length; i++) {
    const count = counts[i];
    total += count;
    if (count > 0) {
      activeDays++;
      run++;
      if (run > longest) longest = run;
    } else {
      run = 0;
    }
    if (count > bestCount) {
      bestCount = count;
      bestIndex = i;
    }
  }

  let current = 0;
  for (let i = counts.length - 1; i >= 0; i--) {
    if (counts[i] > 0) current++;
    else break;
  }

  return { counts, stats: { total, activeDays, longest, current, bestIndex, bestCount } };
}

function levelFor(count: number, scale: number): number {
  if (count <= 0) return 0;
  const q = count / scale;
  if (q < 0.25) return 1;
  if (q < 0.5) return 2;
  if (q < 0.78) return 3;
  return 4;
}

function addCommas(value: number): string {
  return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

function formatFull(date: Date): string {
  return `${WEEKDAYS[date.getDay()]}, ${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
}

function formatShort(date: Date): string {
  return `${MONTHS_SHORT[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
}

function PulseIcon() {
  return (
    <svg
      width="15"
      height="15"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M3 12h4l2 6 4-15 2 9h6" />
    </svg>
  );
}

function CalendarIcon() {
  return (
    <svg
      width="15"
      height="15"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <rect x="3" y="5" width="18" height="16" rx="2.5" />
      <path d="M16 3v4M8 3v4M3 11h18" />
    </svg>
  );
}

function FlameIcon() {
  return (
    <svg
      width="15"
      height="15"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M12 2.5c3.2 3.1 5.2 6 5.2 9.4a5.2 5.2 0 0 1-10.4 0c0-1.6.6-3 1.7-4.2.5 1.1 1.2 1.7 2 1.9-.4-2.4.1-4.7 1.5-7.1Z" />
    </svg>
  );
}

function SparkIcon() {
  return (
    <svg
      width="14"
      height="14"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8" />
    </svg>
  );
}

export default function ChartHeatmap() {
  const reduced = useReducedMotion();

  const calendar = useMemo(() => buildCalendar(), []);
  const datasets = useMemo(() => {
    const map: Record<string, Dataset> = {};
    for (const metric of METRICS) map[metric.id] = buildDataset(calendar.days, metric);
    return map;
  }, [calendar]);

  const [metricId, setMetricId] = useState<string>(METRICS[0].id);
  const [focus, setFocus] = useState<{ row: number; col: number }>({ row: 6, col: WEEKS - 1 });
  const [active, setActive] = useState<{ row: number; col: number; x: number; y: number } | null>(
    null,
  );

  const cardRef = useRef<HTMLDivElement | null>(null);
  const cellRefs = useRef<(HTMLButtonElement | null)[][]>([]);
  const radioRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const metric = METRICS.find((m) => m.id === metricId) ?? METRICS[0];
  const { counts, stats } = datasets[metricId] ?? datasets[METRICS[0].id];
  const singular = metric.unit.endsWith("s") ? metric.unit.slice(0, -1) : metric.unit;

  const activeDay = active ? calendar.matrix[active.row][active.col] : null;
  const activeCount = activeDay ? counts[activeDay.index] : 0;
  const bestDate = calendar.days[stats.bestIndex].date;

  const rangeText = `${MONTHS_SHORT[calendar.start.getMonth()]} ${calendar.start.getFullYear()} – ${
    MONTHS_SHORT[calendar.end.getMonth()]
  } ${calendar.end.getFullYear()}`;

  const activate = (row: number, col: number, el: HTMLButtonElement) => {
    const card = cardRef.current;
    if (!card) return;
    const cardRect = card.getBoundingClientRect();
    const cellRect = el.getBoundingClientRect();
    setActive({
      row,
      col,
      x: cellRect.left - cardRect.left + cellRect.width / 2,
      y: cellRect.top - cardRect.top,
    });
  };

  const labelFor = (day: CalDay, count: number) => {
    const amount = count > 0 ? `${count} ${count === 1 ? singular : metric.unit}` : `No ${metric.unit}`;
    return `${amount} on ${formatFull(day.date)}`;
  };

  const cardMotion = reduced
    ? {}
    : {
        initial: { opacity: 0, y: 18 },
        whileInView: { opacity: 1, y: 0 },
        viewport: { once: true, amount: 0.15 },
        transition: { duration: 0.6, ease: "easeOut" as const },
      };

  const tiles = [
    {
      key: "total",
      label: `Total ${metric.unit}`,
      value: addCommas(stats.total),
      sub: "this year",
      icon: <PulseIcon />,
    },
    {
      key: "active",
      label: "Active days",
      value: addCommas(stats.activeDays),
      sub: `of ${TOTAL_DAYS}`,
      icon: <CalendarIcon />,
    },
    {
      key: "longest",
      label: "Longest streak",
      value: addCommas(stats.longest),
      sub: "days",
      icon: <FlameIcon />,
    },
    {
      key: "current",
      label: "Current streak",
      value: addCommas(stats.current),
      sub: "days",
      icon: <FlameIcon />,
    },
  ];

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 dark:bg-slate-950 sm:px-6 sm:py-20 lg:px-8">
      <style>{`
        @keyframes chm-glow {
          0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
          50% { box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.4); }
        }
        @keyframes chm-rise {
          from { opacity: 0; transform: translateY(8px); }
          to { opacity: 1; transform: none; }
        }
        @media (prefers-reduced-motion: reduce) {
          .chm-anim { animation: none !important; }
          .chm-cell { transition: none !important; transform: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-28 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-emerald-400/10 blur-3xl dark:bg-emerald-500/10"
      />

      <motion.div
        ref={cardRef}
        {...cardMotion}
        className="relative mx-auto max-w-5xl rounded-3xl border border-slate-200 bg-white p-5 shadow-xl shadow-slate-950/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/30 sm:p-8"
      >
        <div className="mb-6 flex flex-col gap-5 sm:mb-8 sm:flex-row sm:items-end sm:justify-between">
          <div>
            <span className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-emerald-600 dark:text-emerald-400">
              <SparkIcon />
              Activity graph
            </span>
            <h2 className="mt-2.5 text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-50 sm:text-3xl">
              A year of shipping
            </h2>
            <p className="mt-2 max-w-md text-sm leading-relaxed text-slate-500 dark:text-slate-400">
              Every square is one day between {rangeText}. Hover a square, or focus the grid and
              walk it with the arrow keys.
            </p>
          </div>

          <div
            role="radiogroup"
            aria-label="Choose which metric to plot"
            className="inline-flex shrink-0 self-start rounded-full border border-slate-200 bg-slate-100 p-1 dark:border-slate-700 dark:bg-slate-800 sm:self-auto"
          >
            {METRICS.map((m, i) => {
              const on = m.id === metric.id;
              return (
                <button
                  key={m.id}
                  ref={(el) => {
                    radioRefs.current[i] = el;
                  }}
                  type="button"
                  role="radio"
                  aria-checked={on}
                  tabIndex={on ? 0 : -1}
                  onClick={() => setMetricId(m.id)}
                  onKeyDown={(e) => {
                    if (
                      e.key !== "ArrowRight" &&
                      e.key !== "ArrowLeft" &&
                      e.key !== "ArrowDown" &&
                      e.key !== "ArrowUp"
                    ) {
                      return;
                    }
                    e.preventDefault();
                    const dir = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : -1;
                    const next = (i + dir + METRICS.length) % METRICS.length;
                    setMetricId(METRICS[next].id);
                    radioRefs.current[next]?.focus();
                  }}
                  className={`rounded-full px-3.5 py-1.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-800 ${
                    on
                      ? "bg-white text-emerald-700 shadow-sm dark:bg-slate-950 dark:text-emerald-300"
                      : "text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100"
                  }`}
                >
                  {m.label}
                </button>
              );
            })}
          </div>
        </div>

        <div className="mb-7 grid grid-cols-2 gap-3 sm:grid-cols-4">
          {tiles.map((tile) => (
            <div
              key={tile.key}
              className="rounded-2xl border border-slate-200 bg-slate-50/70 p-4 dark:border-slate-800 dark:bg-slate-800/40"
            >
              <div className="flex items-center gap-1.5 text-slate-500 dark:text-slate-400">
                {tile.icon}
                <span className="text-[11px] font-medium uppercase tracking-wide">{tile.label}</span>
              </div>
              <div className="mt-2 flex items-baseline gap-1.5">
                <span className="text-2xl font-semibold tabular-nums text-slate-900 dark:text-slate-50">
                  {tile.value}
                </span>
                <span className="text-xs text-slate-500 dark:text-slate-400">{tile.sub}</span>
              </div>
            </div>
          ))}
        </div>

        <div className="overflow-x-auto pb-2">
          <div className="flex w-max px-1" style={{ gap: HEADER_GAP }}>
            <div
              aria-hidden="true"
              className="relative shrink-0"
              style={{ width: WEEK_COL_W, height: MONTH_H + HEADER_GAP + ROWS * PITCH - GAP }}
            >
              {WEEKDAY_LABELS.map((label) => (
                <span
                  key={label.text}
                  className="absolute right-1.5 text-[10px] leading-none text-slate-500 dark:text-slate-400"
                  style={{ top: MONTH_H + HEADER_GAP + label.row * PITCH + 3 }}
                >
                  {label.text}
                </span>
              ))}
            </div>

            <div className="flex flex-col" style={{ gap: HEADER_GAP }}>
              <div
                aria-hidden="true"
                className="relative shrink-0"
                style={{ height: MONTH_H, width: WEEKS * PITCH }}
              >
                {calendar.monthLabels.map((label) => (
                  <span
                    key={label.col}
                    className="absolute top-0 text-[10px] font-medium leading-none text-slate-500 dark:text-slate-400"
                    style={{ left: label.col * PITCH }}
                  >
                    {label.text}
                  </span>
                ))}
              </div>

              <div
                role="grid"
                aria-label={`${metric.label} heatmap, ${addCommas(stats.total)} ${metric.unit} across ${TOTAL_DAYS} days. Use the arrow keys to move between days.`}
                className="flex flex-col outline-none"
                style={{ gap: GAP }}
                onMouseLeave={() => setActive(null)}
                onBlur={(e) => {
                  if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setActive(null);
                }}
                onKeyDown={(e) => {
                  if (e.key === "Escape") {
                    if (active) {
                      e.preventDefault();
                      setActive(null);
                    }
                    return;
                  }
                  let { row, col } = focus;
                  if (e.key === "ArrowRight") col = Math.min(WEEKS - 1, col + 1);
                  else if (e.key === "ArrowLeft") col = Math.max(0, col - 1);
                  else if (e.key === "ArrowDown") row = Math.min(ROWS - 1, row + 1);
                  else if (e.key === "ArrowUp") row = Math.max(0, row - 1);
                  else if (e.key === "Home") col = 0;
                  else if (e.key === "End") col = WEEKS - 1;
                  else return;
                  e.preventDefault();
                  setFocus({ row, col });
                  cellRefs.current[row]?.[col]?.focus();
                }}
              >
                {Array.from({ length: ROWS }, (_, row) => (
                  <div key={row} role="row" className="flex" style={{ gap: GAP }}>
                    {Array.from({ length: WEEKS }, (_, col) => {
                      const day = calendar.matrix[row][col];
                      const count = counts[day.index];
                      const level = levelFor(count, metric.scale);
                      const isBest = day.index === stats.bestIndex;
                      const isFocus = focus.row === row && focus.col === col;
                      return (
                        <button
                          key={col}
                          ref={(el) => {
                            if (!cellRefs.current[row]) cellRefs.current[row] = [];
                            cellRefs.current[row][col] = el;
                          }}
                          type="button"
                          role="gridcell"
                          tabIndex={isFocus ? 0 : -1}
                          aria-label={labelFor(day, count)}
                          onMouseEnter={(e) => activate(row, col, e.currentTarget)}
                          onFocus={(e) => {
                            setFocus({ row, col });
                            activate(row, col, e.currentTarget);
                          }}
                          onClick={() => setFocus({ row, col })}
                          className={`${LEVEL_CLASSES[level]} ${
                            isBest ? "chm-anim" : ""
                          } chm-cell shrink-0 rounded-[3px] outline-none ring-emerald-500 transition-transform duration-150 hover:scale-125 focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900`}
                          style={{
                            width: CELL,
                            height: CELL,
                            animation:
                              isBest && !reduced ? "chm-glow 2.6s ease-in-out infinite" : undefined,
                          }}
                        />
                      );
                    })}
                  </div>
                ))}
              </div>
            </div>
          </div>
        </div>

        <div className="mt-5 flex flex-col gap-3 border-t border-slate-100 pt-5 dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
          <p
            className="chm-anim text-xs text-slate-500 dark:text-slate-400"
            style={{ animation: reduced ? undefined : "chm-rise 0.6s ease-out both" }}
          >
            Busiest day —{" "}
            <span className="font-medium text-slate-800 dark:text-slate-100">
              {formatShort(bestDate)}
            </span>{" "}
            with {stats.bestCount} {stats.bestCount === 1 ? singular : metric.unit}.
          </p>

          <div
            aria-hidden="true"
            className="flex items-center gap-1.5 text-[11px] text-slate-500 dark:text-slate-400"
          >
            <span>Less</span>
            {LEVEL_CLASSES.map((cls, i) => (
              <span
                key={i}
                className={`${cls} rounded-[3px]`}
                style={{ width: CELL, height: CELL }}
              />
            ))}
            <span>More</span>
          </div>
        </div>

        {active && activeDay && (
          <motion.div
            key="chm-tip"
            aria-hidden="true"
            initial={reduced ? false : { opacity: 0 }}
            animate={{ opacity: 1 }}
            transition={{ duration: 0.12, ease: "easeOut" }}
            className="pointer-events-none absolute z-30 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-lg border border-slate-700 bg-slate-900 px-2.5 py-1.5 text-xs shadow-lg dark:border-slate-600 dark:bg-slate-800"
            style={{ left: active.x, top: active.y - 10 }}
          >
            <span className="font-semibold text-white">
              {activeCount > 0
                ? `${activeCount} ${activeCount === 1 ? singular : metric.unit}`
                : `No ${metric.unit}`}
            </span>
            <span className="text-slate-300 dark:text-slate-400">
              {" · "}
              {formatShort(activeDay.date)}
            </span>
            <span className="absolute left-1/2 top-full h-2 w-2 -translate-x-1/2 -translate-y-1/2 rotate-45 border-b border-r border-slate-700 bg-slate-900 dark:border-slate-600 dark:bg-slate-800" />
          </motion.div>
        )}
      </motion.div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →