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-motionPreview
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>
);
}