Pulse Heatmap

GitHub-style contribution grid where cells spring in with a diagonal stagger and lift with a glow on hover

heatmapcalendarcontributiondata-vizgridstagger
Install dependencies
$npm install framer-motion
Preview

Source Code

"use client";

import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";

export interface PulseHeatmapProps {
  data: number[][];
  max?: number;
  colorScale?: [string, string, string, string, string];
  cellSize?: number;
  gap?: number;
  /** Per-step delay multiplier for the diagonal stagger (ms). Default 14. */
  stagger?: number;
  formatTooltip?: (value: number, row: number, col: number) => string;
  /** Disable load-in animation. Default false. */
  staticReveal?: boolean;
  className?: string;
}

type ColorScale = NonNullable<PulseHeatmapProps["colorScale"]>;

const DEFAULT_SCALE: ColorScale = [
  "#1f2330",
  "#1c4a36",
  "#1f7d4a",
  "#39c97c",
  "#7df0ad",
];

function pickColor(scale: ColorScale, value: number, max: number) {
  if (max <= 0 || value <= 0) return scale[0];
  const t = Math.min(1, value / max);
  const idx = Math.min(scale.length - 1, Math.max(1, Math.ceil(t * (scale.length - 1))));
  return scale[idx];
}

export default function PulseHeatmap({
  data,
  max,
  colorScale = DEFAULT_SCALE,
  cellSize = 14,
  gap = 4,
  stagger = 14,
  formatTooltip = (v, r, c) => `${v} on row ${r + 1}, col ${c + 1}`,
  staticReveal = false,
  className = "",
}: PulseHeatmapProps) {
  const [hover, setHover] = useState<{ row: number; col: number } | null>(null);

  if (!data?.length || !data[0]?.length) return null;

  const computedMax = max ?? Math.max(1, ...data.flat());
  const cols = data[0].length;

  const hoveredValue = hover ? data[hover.row][hover.col] : null;
  const tooltipLeft =
    hover != null ? hover.col * (cellSize + gap) + cellSize / 2 : 0;
  const tooltipTop = hover != null ? hover.row * (cellSize + gap) - 6 : 0;

  return (
    <div
      className={`relative inline-block ${className}`}
      style={{
        padding: `${gap}px`,
      }}
    >
      <div
        role="grid"
        className="grid"
        style={{
          gridTemplateColumns: `repeat(${cols}, ${cellSize}px)`,
          gap: `${gap}px`,
        }}
      >
        {data.map((row, r) =>
          row.map((v, c) => {
            const fill = pickColor(colorScale, v, computedMax);
            const delay = staticReveal ? 0 : ((r + c) * stagger) / 1000;
            const isActive = hover?.row === r && hover?.col === c;
            return (
              <motion.div
                key={`${r}-${c}`}
                role="gridcell"
                aria-label={formatTooltip(v, r, c)}
                initial={
                  staticReveal
                    ? { opacity: 1, scale: 1, backgroundColor: fill }
                    : { opacity: 0, scale: 0.2, backgroundColor: colorScale[0] }
                }
                animate={{
                  opacity: 1,
                  scale: isActive ? 1.25 : 1,
                  backgroundColor: fill,
                  boxShadow: isActive ? `0 0 12px ${fill}` : "0 0 0 transparent",
                }}
                transition={{
                  default: { type: "spring", stiffness: 320, damping: 22, delay },
                  scale: { type: "spring", stiffness: 380, damping: 18 },
                  boxShadow: { duration: 0.18 },
                }}
                onMouseEnter={() => setHover({ row: r, col: c })}
                onMouseLeave={() =>
                  setHover((cur) => (cur?.row === r && cur?.col === c ? null : cur))
                }
                style={{
                  width: cellSize,
                  height: cellSize,
                  borderRadius: Math.max(2, cellSize * 0.18),
                  willChange: "transform",
                  cursor: "default",
                }}
              />
            );
          }),
        )}
      </div>

      <AnimatePresence>
        {hover && hoveredValue !== null && (
          <motion.div
            key="tooltip"
            initial={{ opacity: 0, y: 4, scale: 0.94 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 4, scale: 0.94 }}
            transition={{ type: "spring", stiffness: 420, damping: 28 }}
            className="pointer-events-none absolute z-10 whitespace-nowrap rounded-md px-2 py-1 text-xs font-medium text-white"
            style={{
              left: tooltipLeft + gap,
              top: tooltipTop,
              transform: "translate(-50%, -100%)",
              background: "rgba(15,15,25,0.96)",
              border: "1px solid rgba(255,255,255,0.08)",
              boxShadow: "0 8px 24px -8px rgba(0,0,0,0.5)",
            }}
          >
            {formatTooltip(hoveredValue, hover.row, hover.col)}
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}