Activity Heatmap

A GitHub-style calendar heatmap of daily contribution counts. Cells are colored across a 5-level intensity scale, grouped into 53 weeks with month labels and day-of-week labels. Hover any cell for a tooltip showing the exact date and count.

Dashboard StatsInspired by Notion
Live PreviewInteractive
ActivityHeatmap.tsx
// Dependencies: react ^18
import React, { useState, useRef } from 'react';

type DataPoint = {
  date: string; // YYYY-MM-DD
  count: number;
};

type Props = {
  data: DataPoint[];
  colorScale?: [string, string, string, string, string];
};

const DEFAULT_SCALE: [string, string, string, string, string] = [
  '#1a1a1a', '#14532d', '#166534', '#16a34a', '#4ade80',
];

function getLevel(count: number): 0 | 1 | 2 | 3 | 4 {
  if (count === 0) return 0;
  if (count <= 2) return 1;
  if (count <= 5) return 2;
  if (count <= 10) return 3;
  return 4;
}

function toDateStr(d: Date): string {
  return d.toISOString().slice(0, 10);
}

function formatDate(dateStr: string): string {
  return new Date(dateStr + 'T12:00:00').toLocaleDateString('en-US', {
    month: 'short', day: 'numeric', year: 'numeric',
  });
}

export function ActivityHeatmap({ data, colorScale = DEFAULT_SCALE }: Props) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [tooltip, setTooltip] = useState<{
    x: number; y: number; date: string; count: number;
  } | null>(null);

  const countMap = new Map(data.map((d) => [d.date, d.count]));
  const total = data.reduce((s, d) => s + d.count, 0);

  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const start = new Date(today);
  start.setDate(start.getDate() - 364 - start.getDay());

  const weeks: string[][] = [];
  const cur = new Date(start);
  for (let w = 0; w < 53; w++) {
    const week: string[] = [];
    for (let d = 0; d < 7; d++) {
      week.push(toDateStr(cur));
      cur.setDate(cur.getDate() + 1);
    }
    weeks.push(week);
  }

  const monthLabels: { label: string; col: number }[] = [];
  let lastMonth = -1;
  weeks.forEach((week, col) => {
    const m = new Date(week[0] + 'T12:00:00').getMonth();
    if (m !== lastMonth) {
      monthLabels.push({
        label: new Date(week[0] + 'T12:00:00').toLocaleDateString('en-US', { month: 'short' }),
        col,
      });
      lastMonth = m;
    }
  });

  const todayStr = toDateStr(today);

  const handleEnter = (
    e: React.MouseEvent<HTMLButtonElement>,
    dateStr: string,
    count: number,
  ) => {
    const container = containerRef.current;
    if (!container) return;
    const cr = container.getBoundingClientRect();
    const er = e.currentTarget.getBoundingClientRect();
    setTooltip({
      x: er.left - cr.left + er.width / 2,
      y: er.top - cr.top,
      date: dateStr,
      count,
    });
  };

  return (
    <div className="w-full bg-[#0a0a0a] border border-[#1a1a1a] rounded-xl p-4">
      <div className="flex items-center justify-between mb-3">
        <span className="text-sm font-semibold text-white">Activity</span>
        <span className="text-[11px] text-zinc-500">
          {total.toLocaleString()} contributions in the last year
        </span>
      </div>
      <div
        ref={containerRef}
        className="relative select-none"
        onMouseLeave={() => setTooltip(null)}
      >
        <div className="overflow-x-auto">
          <div className="min-w-max">
            {/* Month labels — fixed-width cells aligned to week columns */}
            <div className="flex gap-[2px] mb-1">
              <div className="shrink-0 w-[22px]" />
              {weeks.map((_, col) => {
                const ml = monthLabels.find((m) => m.col === col);
                return (
                  <div key={col} className="shrink-0 w-[10px] text-[9px] text-zinc-600 overflow-hidden">
                    {ml?.label ?? ''}
                  </div>
                );
              })}
            </div>

            {/* Grid */}
            <div className="flex gap-[2px]">
              {/* Day labels */}
              <div className="flex flex-col gap-[2px] pr-1">
                {['', 'M', '', 'W', '', 'F', ''].map((label, i) => (
                  <div
                    key={i}
                    className="w-4 h-[10px] flex items-center justify-end text-[9px] text-zinc-700"
                  >
                    {label}
                  </div>
                ))}
              </div>

              {/* Weeks */}
              {weeks.map((week, wi) => (
                <div key={wi} className="flex flex-col gap-[2px]">
                  {week.map((dateStr) => {
                    const count = countMap.get(dateStr) ?? 0;
                    const isFuture = dateStr > todayStr;
                    const level = isFuture ? 0 : getLevel(count);
                    return (
                      <button
                        key={dateStr}
                        className="w-[10px] h-[10px] rounded-[2px] focus:outline-none focus-visible:ring-1 focus-visible:ring-indigo-500/60"
                        style={{
                          backgroundColor: colorScale[level],
                          opacity: isFuture ? 0.25 : 1,
                        }}
                        onMouseEnter={(e) => !isFuture && handleEnter(e, dateStr, count)}
                        aria-label={isFuture ? undefined : `${count} contributions on ${formatDate(dateStr)}`}
                        tabIndex={isFuture ? -1 : 0}
                      />
                    );
                  })}
                </div>
              ))}
            </div>
          </div>
        </div>

        {/* Tooltip — outside scroll container so it isn't clipped */}
        {tooltip && (
          <div
            className="absolute pointer-events-none z-50 bg-[#1a1a1a] border border-[#2a2a2a] rounded-md px-2 py-1 text-[11px] whitespace-nowrap shadow-xl"
            style={{
              left: tooltip.x,
              top: tooltip.y > 60 ? tooltip.y - 44 : tooltip.y + 16,
              transform: 'translateX(-50%)',
            }}
          >
            <span className="font-semibold text-white">{tooltip.count}</span>
            <span className="text-zinc-400"> contributions · {formatDate(tooltip.date)}</span>
          </div>
        )}

        {/* Legend */}
        <div className="flex items-center gap-1 mt-2 justify-end">
          <span className="text-[9px] text-zinc-600 mr-0.5">Less</span>
          {colorScale.map((color, i) => (
            <div key={i} className="w-[10px] h-[10px] rounded-[2px]" style={{ backgroundColor: color }} />
          ))}
          <span className="text-[9px] text-zinc-600 ml-0.5">More</span>
        </div>
      </div>
    </div>
  );
}

// Usage:
// const data = [
//   { date: '2025-06-01', count: 4 },
//   { date: '2025-06-02', count: 12 },
// ];
// <ActivityHeatmap data={data} />

Unlock to copy

Free access to all patterns