UIData Displayno dependencies

ContributionHeatmap

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

1,235 contributionsin the last 40 weeks

Customize

11 props
40
7
12
3
3
12

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add contribution-heatmap

Writes 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.

example.tsx
1import { ContributionHeatmap } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <ContributionHeatmap />6  );7}

Props

PropTypeDefault
data

Explicit daily counts, oldest first, aligned so the last entry falls on endDate. Overrides the generated demo data.

number[]undefined
weeks

Number of week columns (1–60).

number40
endDate

Last day of the calendar as YYYY-MM-DD (UTC); a fixed date keeps server and client output identical.

string'2026-10-16'
seed

Seed for the deterministic demo data.

number7
accentColor

Base color; levels 1–4 are color-mixed from it over the empty-cell surface.

string'#6366f1'
unit

Noun used in the header, tooltip and announcements.

string'contributions'
cellSize

Cell size in px.

number12
cellGap

Gap between cells in px.

number3
cellRadius

Cell corner radius in px.

number3
showLabels

Show month labels above and Mon/Wed/Fri labels to the left.

booleantrue
showLegend

Show the Less/More intensity legend.

booleantrue
stagger

Per-diagonal delay of the cell pop-in, in ms.

number12
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/contribution-heatmap.tsx
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}