{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "contribution-graph",
  "type": "registry:ui",
  "title": "Contribution Graph",
  "description": "A year of daily activity as a keyboard-walkable heatmap with a gliding tooltip.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/contribution-graph.tsx",
      "content": "\"use client\";\nimport { animate, AnimatePresence, motion, useInView, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\nimport { ease, spring } from \"@/lib/motion\";\n\nconst noop = () => () => {};\nfunction useLocale(locale?: string) {\n  const detected = useSyncExternalStore(noop, () => Intl.DateTimeFormat().resolvedOptions().locale, () => \"en-US\");\n  return locale ?? detected;\n}\n\nconst DAY = 86_400_000;\n/** \"2026-03-04\" to a UTC timestamp, so a day never shifts with the reader's time zone. */\nconst parse = (iso: string) => Date.UTC(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10));\nconst iso = (t: number) => new Date(t).toISOString().slice(0, 10);\n\nexport type ContributionDay = { date: string; count: number };\n\nexport type ContributionGraphProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"onSelect\"> & {\n  /** One entry per day with activity, as ISO dates. Missing days count as zero. */\n  data: ContributionDay[];\n  /** The last day shown, as an ISO date. Defaults to the latest date in data. */\n  endDate?: string;\n  /** How many weeks to show, ending with endDate's week. */\n  weeks?: number;\n  /** 0 for Sunday, 1 for Monday. */\n  weekStartsOn?: 0 | 1;\n  /** Lower bounds for levels 1–4. Defaults to quarters of the busiest day. */\n  thresholds?: [number, number, number, number];\n  /** Singular and plural nouns for the tooltip and labels. */\n  unit?: [string, string];\n  /** Names the graph for screen readers. */\n  label?: string;\n  locale?: string;\n  /** The selected day (controlled), as an ISO date. */\n  selected?: string | null;\n  defaultSelected?: string | null;\n  onSelectedChange?: (date: string | null) => void;\n  /** Largest cell size in pixels. Cells shrink to fit the width down to 9px; below that the graph scrolls sideways, pinned to the latest week. */\n  cellSize?: number;\n  legend?: boolean;\n  loading?: boolean;\n  /** Fade the weeks in, oldest first, the first time it scrolls into view. */\n  animated?: boolean;\n};\n\n// Level 0 is a visible empty cell; 1–4 step the foreground up.\nconst levelClass = [\"bg-fg/[0.06]\", \"bg-fg/25\", \"bg-fg/45\", \"bg-fg/70\", \"bg-fg/95\"];\n\nexport function ContributionGraph({\n  data,\n  endDate,\n  weeks = 53,\n  weekStartsOn = 0,\n  thresholds,\n  unit = [\"contribution\", \"contributions\"],\n  label = \"Activity\",\n  locale: localeProp,\n  selected: selectedProp,\n  defaultSelected = null,\n  onSelectedChange,\n  cellSize: maxCell = 12,\n  legend = true,\n  loading = false,\n  animated = true,\n  className,\n  ...rest\n}: ContributionGraphProps) {\n  const locale = useLocale(localeProp);\n  const reduce = useReducedMotion();\n  const root = useRef<HTMLDivElement>(null);\n  const scroller = useRef<HTMLDivElement>(null);\n  const inView = useInView(root, { once: true, amount: 0.3 });\n  const [selected, setSelected] = useControllableState({ value: selectedProp, defaultValue: defaultSelected, onChange: onSelectedChange });\n  const [active, setActive] = useState<string | null>(null);\n  const [rover, setRover] = useState<string | null>(null);\n  const [tip, setTip] = useState<{ date: string; keyboard: boolean } | null>(null);\n  const tipId = useId();\n  // Fit the year to the width: cells shrink to the space available, never below 9px.\n  const [avail, setAvail] = useState(0);\n  const [clipped, setClipped] = useState(false);\n  useEffect(() => {\n    const el = scroller.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([e]) => setAvail(e.contentRect.width));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  const gap = 3;\n  const cellSize = avail ? Math.max(9, Math.min(maxCell, Math.floor((avail + gap) / weeks) - gap)) : maxCell;\n  const step = cellSize + gap;\n\n  const counts = useMemo(() => {\n    const m = new Map<string, number>();\n    for (const d of data) m.set(d.date, (m.get(d.date) ?? 0) + d.count);\n    return m;\n  }, [data]);\n\n  const end = useMemo(() => parse(endDate ?? data.reduce((max, d) => (d.date > max ? d.date : max), \"1970-01-01\")), [endDate, data]);\n  // The grid starts on the first day of the week, `weeks - 1` weeks before endDate's week.\n  const endDow = (new Date(end).getUTCDay() - weekStartsOn + 7) % 7;\n  const start = end - endDow * DAY - (weeks - 1) * 7 * DAY;\n\n  const max = Math.max(0, ...Array.from(counts.values()));\n  const bounds = thresholds ?? [1, Math.max(2, Math.ceil(max * 0.25)), Math.max(3, Math.ceil(max * 0.5)), Math.max(4, Math.ceil(max * 0.75))];\n  const levelOf = (c: number) => (c <= 0 ? 0 : c >= bounds[3] ? 4 : c >= bounds[2] ? 3 : c >= bounds[1] ? 2 : 1);\n\n  const long = useMemo(() => new Intl.DateTimeFormat(locale, { weekday: \"long\", month: \"long\", day: \"numeric\", year: \"numeric\", timeZone: \"UTC\" }), [locale]);\n  const short = useMemo(() => new Intl.DateTimeFormat(locale, { weekday: \"short\", month: \"short\", day: \"numeric\", timeZone: \"UTC\" }), [locale]);\n  const month = useMemo(() => new Intl.DateTimeFormat(locale, { month: \"short\", timeZone: \"UTC\" }), [locale]);\n  const weekday = useMemo(() => new Intl.DateTimeFormat(locale, { weekday: \"short\", timeZone: \"UTC\" }), [locale]);\n  const nf = useMemo(() => new Intl.NumberFormat(locale), [locale]);\n  const say = (c: number) => (c === 0 ? `No ${unit[1]}` : `${nf.format(c)} ${c === 1 ? unit[0] : unit[1]}`);\n\n  // Month labels sit over the first week that contains the 1st, unless that would crowd the previous label.\n  const months: { col: number; text: string }[] = [];\n  for (let w = 0; w < weeks; w++) {\n    for (let d = 0; d < 7; d++) {\n      const t = start + (w * 7 + d) * DAY;\n      if (t > end) break;\n      if (new Date(t).getUTCDate() === 1 || (w === 0 && d === 0)) {\n        const last = months[months.length - 1];\n        const text = month.format(t);\n        if (!last || w - last.col >= 3) months.push({ col: w, text });\n        else if (w === 0 || new Date(t).getUTCDate() === 1) months[months.length - 1] = { col: w, text };\n        break;\n      }\n    }\n  }\n  // A first label for a partial month that gets crowded out is dropped instead.\n  if (months.length > 1 && months[1].col - months[0].col < 3) months.shift();\n\n  const total = Array.from(counts.entries()).reduce((s, [d, c]) => (parse(d) >= start && parse(d) <= end ? s + c : s), 0);\n  const focusDate = rover && parse(rover) >= start && parse(rover) <= end ? rover : (selected ?? iso(end));\n\n  // Land on the latest week: the right end of the graph is the part people read first.\n  useEffect(() => {\n    const el = scroller.current;\n    if (el) el.scrollLeft = el.scrollWidth;\n  }, [weeks, cellSize, avail]);\n\n  // One tooltip for the whole graph, glided between cells.\n  const tx = useMotionValue(0);\n  const ty = useMotionValue(0);\n  // Shifting by the same share of its own width keeps the tooltip inside the graph at both ends.\n  const shift = useTransform(tx, (x) => `${-Math.min(1, Math.max(0, x / (root.current?.clientWidth || 1))) * 100}%`);\n  const shown = useRef(false);\n  const place = (el: HTMLElement, instant: boolean) => {\n    const box = root.current?.getBoundingClientRect();\n    const r = el.getBoundingClientRect();\n    if (!box) return;\n    const x = r.left - box.left + r.width / 2;\n    const y = r.top - box.top;\n    if (instant || !shown.current || reduce) {\n      tx.jump(x);\n      ty.jump(y);\n    } else {\n      animate(tx, x, spring.follow);\n      animate(ty, y, spring.follow);\n    }\n    shown.current = true;\n  };\n  const hide = () => {\n    shown.current = false;\n    setTip(null);\n    setActive(null);\n  };\n\n  const cellFrom = (e: React.SyntheticEvent) => (e.target as HTMLElement).closest<HTMLElement>(\"[data-date]\");\n\n  const move = (from: string, key: string) => {\n    const byKey: Record<string, number> = { ArrowLeft: -7, ArrowRight: 7, ArrowUp: -1, ArrowDown: 1, PageUp: -28, PageDown: 28 };\n    if (!(key in byKey)) return null;\n    return iso(Math.min(end, Math.max(start, parse(from) + byKey[key] * DAY)));\n  };\n\n  const shownGrid = !animated || (inView && avail > 0);\n  const rows = Array.from({ length: 7 }, (_, d) => d);\n  const labelCol = 28;\n\n  return (\n    <div\n      ref={root}\n      data-slot=\"contribution-graph\"\n      data-state={loading ? \"loading\" : \"ready\"}\n      aria-busy={loading || undefined}\n      className={cn(\"relative flex w-full min-w-0 flex-col gap-2 text-fg\", className)}\n      {...rest}\n    >\n      <div className=\"flex\">\n        {/* Weekday labels sit outside the scroller, so they stay put while the weeks scroll. */}\n        <div aria-hidden className=\"relative shrink-0\" style={{ width: labelCol }}>\n          {rows.map((d) =>\n            d % 2 === 1 ? (\n              <div key={d} className=\"absolute left-0 text-2xs leading-none text-fg-3\" style={{ top: 18 + d * step + cellSize / 2 - 5 }}>\n                {weekday.format(start + d * DAY)}\n              </div>\n            ) : null,\n          )}\n        </div>\n      <div\n        ref={scroller}\n        onScroll={(e) => setClipped(e.currentTarget.scrollLeft > 1)}\n        // When earlier weeks are scrolled out of view, the left edge fades so the cut reads as \"more this way\".\n        className={cn(\n          \"min-w-0 flex-1 overflow-x-auto overscroll-x-contain pb-1 [scrollbar-width:thin]\",\n          clipped && \"[mask-image:linear-gradient(to_right,transparent,black_20px)]\",\n        )}\n      >\n        <div className=\"relative flex w-max\">\n          <div className=\"flex flex-col\">\n            <div aria-hidden className=\"relative h-[18px] text-2xs text-fg-3\">\n              {months.map((m) => (\n                <span key={`${m.col}-${m.text}`} className=\"absolute top-0 whitespace-nowrap\" style={{ left: m.col * step }}>\n                  {m.text}\n                </span>\n              ))}\n            </div>\n\n            <div\n              role=\"grid\"\n              aria-label={`${label}: ${say(total)} from ${short.format(start)} to ${short.format(end)}`}\n              aria-describedby={tip ? tipId : undefined}\n              aria-readonly\n              className=\"flex flex-col outline-none\"\n              style={{ gap }}\n              onPointerOver={(e) => {\n                const cell = cellFrom(e);\n                if (!cell || loading) return;\n                const date = cell.dataset.date!;\n                setActive(date);\n                setTip({ date, keyboard: false });\n                place(cell, e.pointerType !== \"mouse\");\n              }}\n              onPointerLeave={hide}\n              onClick={(e) => {\n                const cell = cellFrom(e);\n                if (!cell || loading) return;\n                const date = cell.dataset.date!;\n                setRover(date);\n                setSelected(selected === date ? null : date);\n              }}\n              onFocus={(e) => {\n                const cell = cellFrom(e);\n                if (!cell) return;\n                const date = cell.dataset.date!;\n                setRover(date);\n                if (cell.matches(\":focus-visible\")) {\n                  setActive(date);\n                  setTip({ date, keyboard: true });\n                  place(cell, true);\n                }\n              }}\n              onBlur={(e) => {\n                if (!e.currentTarget.contains(e.relatedTarget as Node)) hide();\n              }}\n              onKeyDown={(e) => {\n                const cell = cellFrom(e);\n                if (!cell) return;\n                const date = cell.dataset.date!;\n                if (e.key === \"Enter\" || e.key === \" \") {\n                  e.preventDefault();\n                  setSelected(selected === date ? null : date);\n                  return;\n                }\n                if (e.key === \"Escape\") {\n                  if (tip) hide();\n                  else if (selected) setSelected(null);\n                  return;\n                }\n                let next: string | null = null;\n                const dow = (new Date(parse(date)).getUTCDay() - weekStartsOn + 7) % 7;\n                // Home and End run along the weekday's row; with Ctrl or Cmd, to the first and last day.\n                if (e.key === \"Home\") next = iso(e.ctrlKey || e.metaKey ? start : Math.max(start, start + dow * DAY));\n                else if (e.key === \"End\") next = iso(e.ctrlKey || e.metaKey ? end : Math.min(end, start + (weeks - 1) * 7 * DAY + dow * DAY));\n                else next = move(date, e.key);\n                if (!next || next === date) {\n                  if (next === date) e.preventDefault();\n                  return;\n                }\n                e.preventDefault();\n                const target = e.currentTarget.querySelector<HTMLElement>(`[data-date=\"${next}\"]`);\n                if (!target) return;\n                target.focus({ preventScroll: true });\n                target.scrollIntoView({ block: \"nearest\", inline: \"nearest\" });\n              }}\n            >\n              {rows.map((d) => (\n                <div role=\"row\" key={d} className=\"flex\" style={{ gap }}>\n                  {Array.from({ length: weeks }, (_, w) => {\n                    const t = start + (w * 7 + d) * DAY;\n                    if (t > end) return <span key={w} aria-hidden style={{ width: cellSize, height: cellSize }} className=\"shrink-0\" />;\n                    const date = iso(t);\n                    const c = counts.get(date) ?? 0;\n                    const level = levelOf(c);\n                    const isSel = selected === date;\n                    return (\n                      <div\n                        key={w}\n                        role=\"gridcell\"\n                        data-date={date}\n                        data-level={level}\n                        data-state={isSel ? \"selected\" : undefined}\n                        aria-selected={isSel}\n                        aria-label={`${say(c)} on ${long.format(t)}`}\n                        tabIndex={date === focusDate ? 0 : -1}\n                        className={cn(\n                          \"relative shrink-0 rounded-[2px] outline-none\",\n                          \"transition-[opacity,scale,background-color] duration-300 ease-out-expo\",\n                          \"outline-1 outline-offset-1\",\n                          \"before:absolute before:-inset-[2px] before:content-['']\",\n                          loading ? \"bg-fg/[0.06] motion-safe:animate-pulse-soft\" : levelClass[level],\n                          active === date && \"outline-solid outline-fg-3\",\n                          isSel && \"outline-solid outline-fg\",\n                          \"focus-visible:outline-solid focus-visible:outline-fg-2\",\n                          shownGrid ? \"scale-100 opacity-100\" : \"scale-50 opacity-0\",\n                          \"motion-reduce:scale-100\",\n                        )}\n                        style={{\n                          width: cellSize,\n                          height: cellSize,\n                          // Weeks arrive oldest to newest in a quick wave, once.\n                          transitionDelay: shownGrid && !reduce ? `${Math.min(w * 8 + d * 6, 520)}ms, ${Math.min(w * 8 + d * 6, 520)}ms, 0ms` : \"0ms\",\n                        }}\n                      />\n                    );\n                  })}\n                </div>\n              ))}\n            </div>\n          </div>\n        </div>\n      </div>\n      </div>\n\n      {legend && (\n        <div className=\"flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 text-2xs text-fg-3\" style={{ paddingLeft: labelCol, maxWidth: labelCol + weeks * step - gap }}>\n          <span className=\"whitespace-nowrap tabular\">\n            {loading ? \"Loading activity…\" : `${say(total)} in ${weeks >= 52 ? \"the last year\" : `the last ${weeks} weeks`}`}\n          </span>\n          <span aria-hidden className=\"flex items-center gap-1\">\n            Less\n            {levelClass.map((c, i) => (\n              <span key={i} className={cn(\"rounded-[2px]\", c)} style={{ width: cellSize - 1, height: cellSize - 1 }} />\n            ))}\n            More\n          </span>\n        </div>\n      )}\n\n      <AnimatePresence>\n        {tip && (\n          <motion.div\n            key=\"tip\"\n            id={tipId}\n            role=\"tooltip\"\n            className=\"pointer-events-none absolute left-0 top-0 z-(--z-tooltip)\"\n            style={{ x: tx, y: ty }}\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.08 } }}\n            transition={{ duration: 0.12, ease: ease.out }}\n          >\n            <motion.div className=\"-translate-y-full pb-2\" style={{ x: shift }}>\n              <div className=\"whitespace-nowrap rounded-md border border-line-2 bg-raised px-2 py-1 text-[11.5px] shadow-pop\">\n                <span className=\"font-medium text-fg tabular\">{say(counts.get(tip.date) ?? 0)}</span>\n                <span className=\"text-fg-3\"> · {short.format(parse(tip.date))}</span>\n              </div>\n            </motion.div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/contribution-graph"
}
