A GitHub-style calendar heatmap whose cells pop in along a diagonal wave, shaded in five intensity levels mixed from a single accent color. Includes month and weekday labels, a Less/More legend and a hover/keyboard tooltip, driven by seeded deterministic demo data or your own daily counts.
Preview
Customize
11 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add contribution-heatmapWrites components/ui/contribution-heatmap.tsx and components/styles/contribution-heatmap.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { ContributionHeatmap } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ContributionHeatmap />6 );7}Props
Accessibility
The cell grid is one focusable role="img" whose aria-label states the total and the date range. Arrow keys move day by day (Up/Down) and week by week (Left/Right), Home/End jump to the first/last day; each move shows the tooltip and announces "n contributions on Day, Mon d, yyyy" in a polite live region, Escape clears. Labels and legend are aria-hidden decoration. Focus is shown with a 2px outline. Under prefers-reduced-motion the cells appear without the pop-in.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/contribution-heatmap.module.css";5 6export interface ContributionHeatmapProps {7 /** Explicit daily counts, oldest first, ending on `endDate` (overrides the generated demo data) */8 data?: number[];9 /** Number of week columns to show */10 weeks?: number;11 /** Last day of the calendar as YYYY-MM-DD */12 endDate?: string;13 /** Seed for the deterministic demo data */14 seed?: number;15 /** Base color; the four active levels are mixed from it */16 accentColor?: string;17 /** Noun used in the header and tooltip */18 unit?: string;19 /** Cell size in px */20 cellSize?: number;21 /** Gap between cells in px */22 cellGap?: number;23 /** Cell corner radius in px */24 cellRadius?: number;25 /** Show month and weekday labels */26 showLabels?: boolean;27 /** Show the Less/More legend */28 showLegend?: boolean;29 /** Per-diagonal delay of the cell reveal in ms */30 stagger?: number;31 /** Optional className merged onto the root */32 className?: string;33 /** Optional inline styles merged onto the root */34 style?: React.CSSProperties;35}36 37const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];38const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];39const DAY_MS = 86400000;40 41function rand(n: number) {42 const x = Math.sin(n * 12.9898 + 78.233) * 43758.5453;43 return x - Math.floor(x);44}45 46function parseDate(s: string) {47 const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s.trim());48 return m ? Date.UTC(+m[1], +m[2] - 1, +m[3]) : Date.UTC(2026, 9, 16);49}50 51const fmtDate = (t: number) => {52 const d = new Date(t);53 return `${DAYS[d.getUTCDay()]}, ${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;54};55 56interface Cell {57 t: number;58 count: number;59 level: number;60 col: number;61 row: number;62}63 64export function ContributionHeatmap({65 data,66 weeks = 40,67 endDate = "2026-10-16",68 seed = 7,69 accentColor = "#6366f1",70 unit = "contributions",71 cellSize = 12,72 cellGap = 3,73 cellRadius = 3,74 showLabels = true,75 showLegend = true,76 stagger = 12,77 className = "",78 style,79}: ContributionHeatmapProps) {80 const [active, setActive] = React.useState<number | null>(null);81 const [kbd, setKbd] = React.useState(false);82 const cols = Math.max(1, Math.min(60, Math.round(weeks)));83 84 const { cells, total, monthLabels } = React.useMemo(() => {85 const end = parseDate(endDate);86 const endRow = new Date(end).getUTCDay();87 const start = end - ((cols - 1) * 7 + endRow) * DAY_MS;88 const days = (cols - 1) * 7 + endRow + 1;89 const raw: number[] = [];90 for (let i = 0; i < days; i++) {91 if (data) {92 raw.push(Math.max(0, data[data.length - days + i] ?? 0));93 continue;94 }95 const dow = new Date(start + i * DAY_MS).getUTCDay();96 const weekend = dow === 0 || dow === 6;97 const r = rand(i * 1.37 + seed * 101.3);98 const burst = rand(Math.floor(i / 9) + seed * 7.1) > 0.7 ? 1.8 : 1;99 const base = (weekend ? 0.35 : 1) * (0.6 + (i / days) * 0.8) * burst;100 raw.push(r < (weekend ? 0.55 : 0.18) ? 0 : Math.round(r * 9 * base));101 }102 const max = Math.max(1, ...raw);103 const out: Cell[] = raw.map((count, i) => ({104 t: start + i * DAY_MS,105 count,106 level: count === 0 ? 0 : Math.min(4, Math.ceil((count / max) * 4)),107 col: Math.floor(i / 7),108 row: i % 7,109 }));110 const labels: Array<{ col: number; text: string }> = [];111 let prev = -1;112 for (let c = 0; c < cols; c++) {113 const mo = new Date(start + c * 7 * DAY_MS).getUTCMonth();114 if (mo !== prev && (labels.length === 0 || c - labels[labels.length - 1].col > 2)) labels.push({ col: c, text: MONTHS[mo] });115 prev = mo;116 }117 return { cells: out, total: raw.reduce((a, b) => a + b, 0), monthLabels: labels };118 }, [data, cols, endDate, seed]);119 120 const step = cellSize + cellGap;121 const label = (c: Cell) => `${c.count === 0 ? "No" : c.count} ${unit} on ${fmtDate(c.t)}`;122 const cur = active !== null ? cells[active] : null;123 124 const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {125 const i = active ?? cells.length - 1;126 const moves: Record<string, number> = { ArrowUp: -1, ArrowDown: 1, ArrowLeft: -7, ArrowRight: 7 };127 let next = -1;128 if (e.key in moves) next = i + moves[e.key];129 else if (e.key === "Home") next = 0;130 else if (e.key === "End") next = cells.length - 1;131 else if (e.key === "Escape") return setActive(null);132 else return;133 e.preventDefault();134 if (next < 0 || next >= cells.length) return;135 setKbd(true);136 setActive(next);137 };138 139 return (140 <div141 className={`${styles.root} ${className}`.trim()}142 style={143 {144 "--mf-ch-accent": accentColor,145 "--mf-ch-size": `${cellSize}px`,146 "--mf-ch-gap": `${cellGap}px`,147 "--mf-ch-radius": `${cellRadius}px`,148 ...style,149 } as React.CSSProperties150 }151 >152 <div className={styles.header}>153 <span className={styles.total}>154 {total.toLocaleString("en-US")} {unit}155 </span>156 <span className={styles.range}>in the last {cols} weeks</span>157 </div>158 <div className={styles.scroller}>159 <div className={styles.layout} data-labels={showLabels ? "" : undefined}>160 {showLabels && (161 <div className={styles.months} aria-hidden="true">162 {monthLabels.map((m) => (163 <span key={`${m.col}-${m.text}`} style={{ left: m.col * step }}>164 {m.text}165 </span>166 ))}167 </div>168 )}169 {showLabels && (170 <div className={styles.days} aria-hidden="true">171 {DAYS.map((d, i) => (172 <span key={d}>{i % 2 === 1 ? d : ""}</span>173 ))}174 </div>175 )}176 <div177 className={styles.grid}178 tabIndex={0}179 role="img"180 aria-label={`${total.toLocaleString("en-US")} ${unit} over ${cols} weeks ending ${fmtDate(parseDate(endDate))}. Use arrow keys to read individual days.`}181 onKeyDown={onKeyDown}182 onBlur={() => kbd && setActive(null)}183 onPointerLeave={() => setActive(null)}184 >185 {cells.map((c, i) => (186 <span187 key={c.t}188 className={styles.cell}189 data-level={c.level}190 data-active={active === i ? "" : undefined}191 style={{ gridColumn: c.col + 1, gridRow: c.row + 1, animationDelay: `${(c.col + c.row) * stagger}ms` } as React.CSSProperties}192 onPointerEnter={() => {193 setKbd(false);194 setActive(i);195 }}196 />197 ))}198 {cur && (199 <div200 className={styles.tooltip}201 aria-hidden="true"202 style={{ left: cur.col * step + cellSize / 2, top: cur.row * step }}203 data-flip={cur.col < 6 ? "start" : cur.col > cols - 7 ? "end" : undefined}204 data-below={cur.row < 3 ? "" : undefined}205 >206 <strong>{cur.count === 0 ? "No" : cur.count} {unit}</strong> on {fmtDate(cur.t)}207 </div>208 )}209 </div>210 </div>211 </div>212 {showLegend && (213 <div className={styles.legend} aria-hidden="true">214 <span>Less</span>215 {[0, 1, 2, 3, 4].map((l) => (216 <span key={l} className={styles.swatch} data-level={l} />217 ))}218 <span>More</span>219 </div>220 )}221 <span className={styles.srOnly} aria-live="polite">222 {kbd && cur ? label(cur) : ""}223 </span>224 </div>225 );226}