{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"activity-heatmap","type":"registry:component","title":"Activity heatmap","description":"Every day as a square, weeks across: one readout glides to the day you point at, and new numbers ripple through week by week.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency"],"files":[{"path":"registry/activity-heatmap.tsx","type":"registry:component","content":"// Hairline UI · activity-heatmap · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * ACTIVITY HEATMAP: every day as a square, weeks across\n *\n *   arrive    the first time it's on screen the days fill in\n *             as a wave from the oldest week to the newest, and\n *             the total rolls up from zero\n *   point     point at a day (or move to it with the arrow keys)\n *             and one readout glides along the rail above the\n *             grid to its week: the count rolls, the date morphs.\n *             At rest the readout sits on the busiest day\n *   level     point at a step of the legend and every day\n *             outside that band steps back\n *   data      new numbers or a new colour ripple across the grid\n *             week by week instead of switching at once\n *   fit       it shows as many of the weeks as fit at a readable\n *             size, newest on the right\n *\n * The grid is a real ARIA grid: one tab stop, arrow keys, Home\n * and End, and each day's count and date as its label.\n * ───────────────────────────────────────────────────────── */\n\nexport interface HeatmapDay {\n  /** \"YYYY-MM-DD\". Days missing from the data count as zero. */\n  date: string;\n  value: number;\n}\n\nexport interface ActivityHeatmapProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n  data: HeatmapDay[];\n  /** The last day shown, \"YYYY-MM-DD\"; defaults to the latest day in the data. */\n  end?: string;\n  /** The most weeks to show; fewer when the card is narrow. */\n  weeks?: number;\n  /** The first row: 0 for Sunday, 1 for Monday. */\n  weekStart?: 0 | 1;\n  /** What's being counted, e.g. { one: \"order\", other: \"orders\" }. */\n  unit?: { one: string; other: string };\n  /** The colour of the busiest days; the other steps are mixed from it. */\n  color?: string;\n  /** Lower bounds of levels 1 to 4. By default they come from the data's own quartiles. */\n  thresholds?: [number, number, number, number];\n  title?: React.ReactNode;\n  description?: React.ReactNode;\n  action?: React.ReactNode;\n  locales?: string | string[];\n  onDayClick?: (day: HeatmapDay) => void;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst DAY = 86_400_000;\nconst GAP = 3;\nconst MIN_CELL = 10;\nconst MAX_CELL = 16;\nconst LABELS = 30;\nconst UNIT = { one: \"contribution\", other: \"contributions\" };\nconst MIX = [0, 28, 52, 76, 100];\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\nconst subscribeNothing = () => () => {};\n/** Dates in the visitor's language wait for hydration, so the server's en-US render and the first client render match. */\nconst useHydrated = () => React.useSyncExternalStore(subscribeNothing, () => true, () => false);\n\nconst parse = (date: string) => Date.parse(`${date}T00:00:00Z`);\nconst iso = (t: number) => new Date(t).toISOString().slice(0, 10);\n\n/** The quartiles of the days that have anything, as the lower bounds of levels 2 to 4 (level 1 is anything above zero). */\nfunction quartiles(values: number[]): [number, number, number, number] {\n  const sorted = values.filter((v) => v > 0).sort((a, b) => a - b);\n  if (!sorted.length) return [1, 2, 3, 4];\n  const at = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))];\n  return [Number.MIN_VALUE, at(0.25), at(0.5), at(0.75)];\n}\n\nconst levelOf = (v: number, t: [number, number, number, number]) => (v <= 0 ? 0 : v >= t[3] ? 4 : v >= t[2] ? 3 : v >= t[1] ? 2 : v >= t[0] ? 1 : 0);\n\nexport function ActivityHeatmap({\n  data,\n  end,\n  weeks = 26,\n  weekStart = 0,\n  unit = UNIT,\n  color = \"var(--chart-3, #1baf7a)\",\n  thresholds,\n  title,\n  description,\n  action,\n  locales,\n  onDayClick,\n  className = \"\",\n  ...props\n}: ActivityHeatmapProps) {\n  const reduced = useReducedMotion();\n  const hydrated = useHydrated();\n  const lang = hydrated ? locales : (locales ?? \"en-US\");\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const bodyRef = React.useRef<HTMLDivElement>(null);\n  const pillRef = React.useRef<HTMLDivElement>(null);\n  const cellRefs = React.useRef(new Map<string, HTMLElement>());\n  const [width, setWidth] = React.useState(0);\n  const [pillWidth, setPillWidth] = React.useState(0);\n  const [visible, setVisible] = React.useState(false);\n  const [pointed, setPointed] = React.useState<string | null>(null);\n  const [focused, setFocused] = React.useState<string | null>(null);\n  const [band, setBand] = React.useState<number | null>(null);\n\n  React.useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          setVisible(true);\n          observer.disconnect();\n        }\n      },\n      { rootMargin: \"0px 0px -10% 0px\" },\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  // Measured before the first paint, so the grid never shows at the wrong size.\n  React.useLayoutEffect(() => {\n    const el = bodyRef.current;\n    if (!el) return;\n    const observer = new ResizeObserver(([entry]) => {\n      const w = Math.floor(entry.contentRect.width);\n      setWidth((prev) => (prev === w ? prev : w));\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useEffect(() => {\n    const el = pillRef.current;\n    if (!el) return;\n    const observer = new ResizeObserver(() => {\n      const w = el.offsetWidth;\n      setPillWidth((prev) => (prev === w ? prev : w));\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  // The weeks that fit: at least MIN_CELL a day, never more than asked for.\n  const room = Math.max(0, width - LABELS);\n  const shown = width ? Math.max(4, Math.min(weeks, Math.floor((room + GAP) / (MIN_CELL + GAP)))) : weeks;\n  const cell = width ? Math.min(MAX_CELL, (room + GAP) / shown - GAP) : MAX_CELL;\n  const gridWidth = shown * (cell + GAP) - GAP;\n\n  const values = new Map<string, number>();\n  for (const d of data) values.set(d.date, (values.get(d.date) ?? 0) + d.value);\n  const lastDate = end ?? data.reduce((latest, d) => (d.date > latest ? d.date : latest), data[0]?.date ?? \"\");\n  const last = lastDate ? parse(lastDate) : 0;\n  const lastRow = last ? (new Date(last).getUTCDay() - weekStart + 7) % 7 : 0;\n  const first = last - ((shown - 1) * 7 + lastRow) * DAY;\n\n  // Columns are weeks, rows are weekdays; days after the last one are left out.\n  const grid = Array.from({ length: 7 }, (_, row) =>\n    Array.from({ length: shown }, (_, col) => {\n      const t = first + (col * 7 + row) * DAY;\n      const date = iso(t);\n      return { date, t, row, col, value: values.get(date) ?? 0, future: t > last };\n    }),\n  );\n  // In date order (the grid itself runs row by row).\n  const days = grid\n    .flat()\n    .filter((d) => !d.future && last)\n    .sort((a, b) => a.t - b.t);\n  const levels = thresholds ?? quartiles(days.map((d) => d.value));\n  const total = days.reduce((sum, d) => sum + d.value, 0);\n  const peak = days.reduce<(typeof days)[number] | null>((best, d) => (!best || d.value > best.value ? d : best), null);\n\n  const current = days.find((d) => d.date === (pointed ?? focused)) ?? (peak && peak.value > 0 ? peak : null);\n  const named = (n: number) => (n === 1 ? unit.one : unit.other);\n  const dayLabel = (t: number, long = false) =>\n    new Intl.DateTimeFormat(lang, { timeZone: \"UTC\", weekday: long ? \"long\" : \"short\", day: \"numeric\", month: long ? \"long\" : \"short\" }).format(t);\n  const month = (t: number) => new Intl.DateTimeFormat(lang, { timeZone: \"UTC\", month: \"short\" }).format(t);\n  const weekday = (row: number) => new Intl.DateTimeFormat(lang, { timeZone: \"UTC\", weekday: \"short\" }).format(Date.UTC(2026, 0, 4 + ((row + weekStart) % 7)));\n\n  // A month's name over the first week that starts in it, if there's room before the next.\n  const months: { col: number; label: string }[] = [];\n  for (let col = 0; col < shown; col++) {\n    const t = grid[0][col].t;\n    const prev = col > 0 ? grid[0][col - 1].t : null;\n    if (prev === null ? new Date(t).getUTCDate() <= 7 : new Date(t).getUTCMonth() !== new Date(prev).getUTCMonth()) {\n      if (!months.length || col - months[months.length - 1].col >= 3) months.push({ col, label: month(t) });\n    }\n  }\n\n  const x = current ? LABELS + current.col * (cell + GAP) + cell / 2 : LABELS;\n  const half = pillWidth / 2;\n  const pillX = Math.min(Math.max(x, half), Math.max(half, LABELS + gridWidth - half));\n\n  const fill = (level: number) =>\n    level === 0 ? \"color-mix(in oklab, var(--foreground) 7%, transparent)\" : `color-mix(in oklab, ${color} ${MIX[level]}%, var(--background))`;\n\n  const move = (date: string) => {\n    setFocused(date);\n    cellRefs.current.get(date)?.focus();\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const at = days.find((d) => d.date === focused) ?? peak ?? days[days.length - 1];\n    if (!at) return;\n    const steps: Record<string, number> = { ArrowLeft: -7, ArrowRight: 7, ArrowUp: -1, ArrowDown: 1 };\n    let next: number | null = null;\n    if (e.key in steps) next = at.t + steps[e.key] * DAY;\n    else if (e.key === \"Home\") next = days[0]?.t ?? null;\n    else if (e.key === \"End\") next = days[days.length - 1]?.t ?? null;\n    else if ((e.key === \"Enter\" || e.key === \" \") && onDayClick) {\n      e.preventDefault();\n      onDayClick({ date: at.date, value: at.value });\n      return;\n    }\n    if (next === null) return;\n    e.preventDefault();\n    if (next >= (days[0]?.t ?? 0) && next <= last) move(iso(next));\n  };\n\n  const wave = (col: number) => (reduced ? \"none\" : `background-color 520ms ${EASE} ${col * 16}ms, opacity 200ms ${EASE}, box-shadow 160ms ${EASE}`);\n  const tabStop = focused ?? current?.date ?? days[days.length - 1]?.date;\n\n  return (\n    <div ref={rootRef} className={`w-full rounded-[22px] bg-background shadow-[0_0_0_1px_var(--border)] ${className}`} {...props}>\n      {/* Its natural width where the parent sizes to its content (a centred flex column), without forcing it on narrow screens. */}\n      <div aria-hidden=\"true\" data-natural-width className=\"grid h-0\" style={{ gridTemplateColumns: \"minmax(0, 640px)\" }} />\n      <div className=\"@container w-full\">\n      <div className=\"p-4 @md:p-5\">\n        {(title || description || action) && (\n          <div className=\"mb-3 flex items-start justify-between gap-3\">\n            <div className=\"min-w-0\">\n              {title && <p className=\"text-[13.5px] font-medium text-foreground @md:text-[14px]\">{title}</p>}\n              {description && <p className=\"mt-0.5 text-[12px] text-muted-foreground @md:text-[12.5px]\">{description}</p>}\n            </div>\n            {action && <div className=\"shrink-0\">{action}</div>}\n          </div>\n        )}\n\n        <p className=\"flex flex-wrap items-baseline gap-x-1.5 text-[12px] text-muted-foreground @md:text-[12.5px]\">\n          <span className=\"text-[22px] font-semibold tracking-[-0.03em] text-foreground tabular-nums @md:text-[26px]\">\n            <NumberRoll value={visible ? total : 0} locales={locales} duration={1000} />\n          </span>\n          <span>\n            {named(total)} in the last {shown} weeks\n          </span>\n        </p>\n\n        <div ref={bodyRef} className=\"relative mt-3 w-full\" onPointerLeave={() => setPointed(null)}>\n          {/* The readout: one pill that glides to the day being pointed at, or rests on the busiest one. */}\n          <div className=\"relative h-7\">\n            <div\n              ref={pillRef}\n              aria-hidden=\"true\"\n              className=\"absolute left-0 top-0 flex h-6 items-center gap-1.5 whitespace-nowrap rounded-full bg-foreground px-2.5 text-[11.5px] text-background\"\n              style={{\n                transform: `translateX(${pillX - half}px)`,\n                opacity: current && width ? 1 : 0,\n                transition: reduced ? \"none\" : `transform 300ms ${EASE}, opacity 200ms ${EASE}`,\n              }}\n            >\n              <span className=\"font-medium tabular-nums\">\n                <NumberRoll value={current?.value ?? 0} locales={locales} duration={600} />\n              </span>\n              <span className=\"opacity-70\">\n                <TextMorph duration={360}>{`${named(current?.value ?? 0)} · ${current ? dayLabel(current.t) : \"\"}`}</TextMorph>\n              </span>\n            </div>\n          </div>\n\n          {/* Month names over the weeks they start in. */}\n          <div aria-hidden=\"true\" className=\"relative h-4 text-[10.5px] text-muted-foreground @md:text-[11px]\" style={{ marginLeft: LABELS }}>\n            {months.map((m) => (\n              <span key={m.col} className=\"absolute top-0\" style={{ left: m.col * (cell + GAP) }}>\n                {m.label}\n              </span>\n            ))}\n          </div>\n\n          <div className=\"mt-1 flex\">\n            <div aria-hidden=\"true\" className=\"grid shrink-0 text-[10.5px] text-muted-foreground @md:text-[11px]\" style={{ width: LABELS, gridTemplateRows: `repeat(7, ${cell}px)`, rowGap: GAP }}>\n              {Array.from({ length: 7 }, (_, row) => (\n                <span key={row} className=\"leading-none\" style={{ lineHeight: `${cell}px` }}>\n                  {(weekStart === 0 ? row % 2 === 1 : row % 2 === 0 && row < 6) ? weekday(row) : \"\"}\n                </span>\n              ))}\n            </div>\n\n            <div\n              role=\"grid\"\n              aria-label={`${total} ${named(total)} in the last ${shown} weeks`}\n              aria-readonly=\"true\"\n              onKeyDown={onKeyDown}\n              onBlur={(e) => {\n                if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(null);\n              }}\n              className=\"grid\"\n              style={{ width: gridWidth, gridTemplateColumns: `repeat(${shown}, ${cell}px)`, gridTemplateRows: `repeat(7, ${cell}px)`, gap: GAP }}\n            >\n              {grid.map((row, r) => (\n                <div key={r} role=\"row\" className=\"contents\">\n                  {row.map((d) => {\n                    const level = levelOf(d.value, levels);\n                    const on = (pointed ?? focused) === d.date;\n                    const out = band !== null && level !== band;\n                    return (\n                      <span\n                        key={d.date}\n                        ref={(el) => {\n                          if (el) cellRefs.current.set(d.date, el);\n                          else cellRefs.current.delete(d.date);\n                        }}\n                        role=\"gridcell\"\n                        aria-label={d.future ? undefined : `${d.value} ${named(d.value)}, ${dayLabel(d.t, true)}`}\n                        aria-hidden={d.future || undefined}\n                        tabIndex={!d.future && d.date === tabStop ? 0 : -1}\n                        onPointerEnter={(e) => e.pointerType === \"mouse\" && !d.future && setPointed(d.date)}\n                        onPointerDown={(e) => e.pointerType !== \"mouse\" && !d.future && setPointed(d.date)}\n                        onFocus={() => setFocused(d.date)}\n                        onClick={() => !d.future && onDayClick?.({ date: d.date, value: d.value })}\n                        className={`rounded-[3px] outline-none focus-visible:ring-2 focus-visible:ring-ring/50 ${onDayClick && !d.future ? \"cursor-pointer\" : \"\"}`}\n                        style={{\n                          gridColumn: d.col + 1,\n                          gridRow: r + 1,\n                          visibility: d.future ? \"hidden\" : undefined,\n                          background: fill(visible ? level : 0),\n                          opacity: out ? 0.22 : 1,\n                          boxShadow: on ? \"inset 0 0 0 1.5px var(--foreground)\" : undefined,\n                          transition: wave(d.col),\n                        }}\n                      />\n                    );\n                  })}\n                </div>\n              ))}\n            </div>\n          </div>\n        </div>\n\n        {/* The scale: point at a step to see only the days in it. */}\n        <div className=\"mt-3 flex items-center justify-end gap-1.5 text-[11px] text-muted-foreground\" onPointerLeave={() => setBand(null)}>\n          <span>Less</span>\n          {[0, 1, 2, 3, 4].map((level) => (\n            <span\n              key={level}\n              aria-hidden=\"true\"\n              onPointerEnter={() => setBand(level)}\n              className=\"size-[11px] cursor-default rounded-[3px] transition-[box-shadow] duration-150\"\n              style={{ background: fill(level), boxShadow: band === level ? \"0 0 0 1.5px var(--foreground)\" : undefined, transition: reduced ? \"none\" : `background-color 520ms ${EASE}` }}\n            />\n          ))}\n          <span>More</span>\n        </div>\n      </div>\n      </div>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/activity-heatmap. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}