{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cohort-grid",
  "type": "registry:component",
  "title": "Cohort Grid",
  "description": "A retention triangle by signup week: hover or arrow through any cell to light its row and column, read it against the average, and flip between percentages and head counts.",
  "categories": [
    "component",
    "Numbers"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/tabs.json"
  ],
  "files": [
    {
      "path": "registry/devl/cohort-grid.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, type PointerEvent, useEffect, useMemo, useRef, useState } from \"react\";\nimport { Tabs, TabsList, TabsTab } from \"@/components/ui/tabs\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface Cohort {\n  label: string;\n  size: number;\n  retained: number[];\n}\n\nexport type CohortMode = \"percent\" | \"count\";\n\nexport interface CohortGridProps {\n  title?: string;\n  caption?: string;\n  cohorts?: Cohort[];\n  periodPrefix?: string;\n  periodName?: string;\n  defaultMode?: CohortMode;\n  color?: string;\n  locale?: string;\n  className?: string;\n}\n\nconst CELL_W = 38;\nconst CELL_H = 30;\nconst LABEL_W = 56;\nconst SIZE_W = 50;\n\nfunction mulberry(seed: number) {\n  return () => {\n    seed |= 0;\n    seed = (seed + 0x6d2b79f5) | 0;\n    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nfunction sampleCohorts(): Cohort[] {\n  const rand = mulberry(42);\n  const weeks = [\"Aug 24\", \"Aug 31\", \"Sep 7\", \"Sep 14\", \"Sep 21\", \"Sep 28\", \"Oct 5\", \"Oct 12\", \"Oct 19\"];\n  return weeks.map((label, i) => {\n    const size = Math.round(980 + rand() * 420 + i * 24);\n    const lift = 1 + i * 0.018;\n    const retained = Array.from({ length: weeks.length - i }, (_, w) => {\n      if (w === 0) return size;\n      const curve = 0.2 + 0.3 * Math.exp(-(w - 1) / 2.4);\n      return Math.round(size * Math.min(0.95, curve * lift * (0.93 + rand() * 0.14)));\n    });\n    return { label, size, retained };\n  });\n}\n\nconst defaultCohorts = sampleCohorts();\n\nexport function CohortGrid({\n  title = \"Retention by signup week\",\n  caption = \"Active users each week after signing up\",\n  cohorts = defaultCohorts,\n  periodPrefix = \"W\",\n  periodName = \"Week\",\n  defaultMode = \"percent\",\n  color = \"var(--color-violet-500)\",\n  locale = \"en-US\",\n  className,\n}: CohortGridProps) {\n  const reduce = useReducedMotion();\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const [mode, setMode] = useState<CohortMode>(defaultMode);\n  const [active, setActive] = useState<{ r: number; c: number } | null>(null);\n  const [tip, setTip] = useState({ x: 0, y: 0 });\n  const [announce, setAnnounce] = useState(\"\");\n  const [moreRight, setMoreRight] = useState(false);\n  useEffect(() => {\n    const el = scrollRef.current;\n    if (!el) return;\n    const check = () => setMoreRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 2);\n    check();\n    const ro = new ResizeObserver(check);\n    ro.observe(el);\n    el.addEventListener(\"scroll\", check, { passive: true });\n    return () => {\n      ro.disconnect();\n      el.removeEventListener(\"scroll\", check);\n    };\n  }, []);\n  const num = useMemo(() => new Intl.NumberFormat(locale), [locale]);\n\n  const periods = Math.max(...cohorts.map((c) => c.retained.length));\n  const avg = useMemo(\n    () =>\n      Array.from({ length: periods }, (_, p) => {\n        let kept = 0;\n        let base = 0;\n        for (const c of cohorts) {\n          if (c.retained[p] === undefined) continue;\n          kept += c.retained[p];\n          base += c.size;\n        }\n        return base ? kept / base : 0;\n      }),\n    [cohorts, periods],\n  );\n\n  const rate = (r: number, c: number) => (r === cohorts.length ? avg[c] : cohorts[r].retained[c] / (cohorts[r].size || 1));\n  const has = (r: number, c: number) => (r === cohorts.length ? avg[c] > 0 : cohorts[r].retained[c] !== undefined);\n  const maxLater = Math.max(...cohorts.flatMap((co) => co.retained.slice(1).map((v) => v / (co.size || 1))), 0.01);\n  const strength = (r: number, c: number) => (c === 0 ? 1 : Math.min(1, rate(r, c) / maxLater));\n  const label = (r: number, c: number) => {\n    if (mode === \"percent\") return `${Math.round(rate(r, c) * 100)}%`;\n    if (r === cohorts.length) return `${Math.round(rate(r, c) * 100)}%`;\n    const v = cohorts[r].retained[c];\n    return v >= 10000 ? `${Math.round(v / 1000)}k` : num.format(v);\n  };\n\n  function describe(r: number, c: number) {\n    if (r === cohorts.length) return `Average, ${periodName.toLowerCase()} ${c}: ${(avg[c] * 100).toFixed(1)}% retained`;\n    const co = cohorts[r];\n    return `${co.label} cohort, ${periodName.toLowerCase()} ${c}: ${num.format(co.retained[c])} of ${num.format(co.size)} active, ${(rate(r, c) * 100).toFixed(1)}%`;\n  }\n\n  function placeAt(r: number, c: number) {\n    const wrap = wrapRef.current;\n    const scroller = scrollRef.current;\n    const cell = wrap?.querySelector<HTMLElement>(`[data-cell=\"${r}-${c}\"]`);\n    if (!wrap || !cell || !scroller) return;\n    const sb = scroller.getBoundingClientRect();\n    let b = cell.getBoundingClientRect();\n    const leftEdge = sb.left + LABEL_W + SIZE_W;\n    if (b.left < leftEdge) scroller.scrollLeft -= leftEdge - b.left + 4;\n    else if (b.right > sb.right) scroller.scrollLeft += b.right - sb.right + 4;\n    b = cell.getBoundingClientRect();\n    const a = wrap.getBoundingClientRect();\n    setTip({ x: b.left - a.left + b.width / 2, y: b.top - a.top });\n  }\n\n  function onPointerMove(e: PointerEvent<HTMLDivElement>) {\n    const target = (e.target as HTMLElement).closest<HTMLElement>(\"[data-cell]\");\n    if (!target || !wrapRef.current) {\n      setActive(null);\n      return;\n    }\n    const [r, c] = target.dataset.cell!.split(\"-\").map(Number);\n    if (active?.r === r && active?.c === c) return;\n    const a = wrapRef.current.getBoundingClientRect();\n    const b = target.getBoundingClientRect();\n    setActive({ r, c });\n    setTip({ x: b.left - a.left + b.width / 2, y: b.top - a.top });\n  }\n\n  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {\n    const cur = active ?? { r: 0, c: 1 };\n    let { r, c } = cur;\n    if (e.key === \"ArrowRight\") c += 1;\n    else if (e.key === \"ArrowLeft\") c -= 1;\n    else if (e.key === \"ArrowDown\") r += 1;\n    else if (e.key === \"ArrowUp\") r -= 1;\n    else if (e.key === \"Home\") c = 0;\n    else if (e.key === \"End\") c = periods - 1;\n    else if (e.key === \"Escape\") return setActive(null);\n    else return;\n    e.preventDefault();\n    r = Math.max(0, Math.min(cohorts.length, r));\n    c = Math.max(0, Math.min(periods - 1, c));\n    while (c > 0 && !has(r, c)) c -= 1;\n    setActive({ r, c });\n    placeAt(r, c);\n    setAnnounce(describe(r, c));\n  }\n\n  const cellBg = (s: number) =>\n    `color-mix(in oklab, ${color} calc(8% + var(--lift) + ${s.toFixed(3)} * (72% - var(--lift))), var(--color-card))`;\n  const rows = [...cohorts.map((c, i) => ({ key: c.label, i })), { key: \"avg\", i: cohorts.length }];\n  const wrapW = wrapRef.current?.clientWidth ?? 480;\n  const below = tip.y < 120;\n  const tipAlign = tip.x < 110 ? \"start\" : tip.x > wrapW - 110 ? \"end\" : \"center\";\n  const activeRate = active ? rate(active.r, active.c) : 0;\n  const vsAvg = active && active.r < cohorts.length ? (activeRate - avg[active.c]) * 100 : null;\n\n  return (\n    <section\n      aria-label={title}\n      className={cn(\"w-full max-w-[540px] rounded-2xl [--lift:0%] dark:[--lift:14%] border bg-card p-5 text-card-foreground shadow-xs/5\", className)}\n    >\n      <header className=\"flex items-start justify-between gap-3\">\n        <div className=\"flex min-w-0 flex-col gap-0.5\">\n          <h3 className=\"font-medium text-sm\">{title}</h3>\n          <p className=\"text-pretty text-muted-foreground text-xs\">{caption}</p>\n        </div>\n        <Tabs onValueChange={(v) => setMode(v as CohortMode)} value={mode}>\n          <TabsList aria-label=\"Show cells as\">\n            <TabsTab className=\"px-2 text-muted-foreground text-xs sm:text-xs\" value=\"percent\">\n              %\n            </TabsTab>\n            <TabsTab className=\"px-2 text-muted-foreground text-xs sm:text-xs\" value=\"count\">\n              Users\n            </TabsTab>\n          </TabsList>\n        </Tabs>\n      </header>\n\n      <div className=\"relative mt-4\" ref={wrapRef}>\n        <div\n          aria-label={`${title}. Use arrow keys to move between cells.`}\n          className=\"overflow-x-auto overscroll-x-contain rounded-lg outline-none ring-offset-2 ring-offset-card [scrollbar-width:none] focus-visible:ring-2 focus-visible:ring-ring\"\n          onBlur={() => setActive(null)}\n          onFocus={(e) => {\n            if (e.target === e.currentTarget && !active) {\n              setActive({ r: 0, c: 1 });\n              requestAnimationFrame(() => placeAt(0, 1));\n            }\n          }}\n          onKeyDown={onKeyDown}\n          onPointerLeave={() => setActive(null)}\n          onPointerMove={onPointerMove}\n          ref={scrollRef}\n          role=\"application\"\n          tabIndex={0}\n        >\n          <div\n            className=\"grid w-max gap-[3px] text-xs tabular-nums\"\n            style={{ gridTemplateColumns: `${LABEL_W}px ${SIZE_W}px repeat(${periods}, ${CELL_W}px)` }}\n          >\n            <div className=\"sticky left-0 z-[1] bg-card text-muted-foreground\">Cohort</div>\n            <div className=\"pe-2 text-right text-muted-foreground\">Users</div>\n            {Array.from({ length: periods }, (_, c) => (\n              <div\n                className={cn(\"text-center text-muted-foreground\", active?.c === c && \"text-foreground\")}\n                key={c}\n              >\n                {periodPrefix}\n                {c}\n              </div>\n            ))}\n\n            {rows.map(({ key, i: r }) => {\n              const isAvg = r === cohorts.length;\n              const rowOn = active?.r === r;\n              return (\n                <div className=\"contents\" key={key}>\n                  {isAvg ? <div className=\"col-span-full my-1 h-px bg-border\" /> : null}\n                  <div\n                    className={cn(\n                      \"sticky left-0 z-[1] flex items-center bg-card text-muted-foreground\",\n                      rowOn && \"text-foreground\",\n                    )}\n                    style={{ height: CELL_H }}\n                  >\n                    {isAvg ? \"Average\" : cohorts[r].label}\n                  </div>\n                  <div\n                    className=\"flex items-center justify-end pe-2 text-muted-foreground\"\n                    style={{ height: CELL_H }}\n                  >\n                    {isAvg ? num.format(Math.round(cohorts.reduce((s, c) => s + c.size, 0) / (cohorts.length || 1))) : num.format(cohorts[r].size)}\n                  </div>\n                  {Array.from({ length: periods }, (_, c) => {\n                    if (!has(r, c)) return <div key={c} />;\n                    const s = c === 0 ? 0 : strength(r, c);\n                    const dim = active && active.r !== r && active.c !== c;\n                    const on = active?.r === r && active?.c === c;\n                    return (\n                      <div className={cn(dim && \"opacity-50\")} key={c}>\n                        <motion.div\n                          animate={{ opacity: 1, scale: 1 }}\n                          className={cn(\n                            \"relative flex items-center justify-center rounded-sm text-[11px]\",\n                            c === 0 ? \"text-muted-foreground\" : \"text-foreground\",\n                            isAvg && \"font-medium\",\n                            on && \"ring-2 ring-foreground ring-offset-1 ring-offset-card\",\n                          )}\n                          data-cell={`${r}-${c}`}\n                          initial={{ opacity: 0, scale: 0.95 }}\n                          style={{ height: CELL_H, background: c === 0 ? \"var(--color-muted)\" : cellBg(s) }}\n                          transition={{ duration: reduce ? 0 : 0.25, delay: reduce ? 0 : Math.min(0.4, (r + c) * 0.025), ease: [0.23, 1, 0.32, 1] }}\n                        >\n                          <AnimatePresence initial={false} mode=\"popLayout\">\n                            <motion.span\n                              animate={{ opacity: 1, y: 0 }}\n                              exit={{ opacity: 0, y: reduce ? 0 : -4 }}\n                              initial={{ opacity: 0, y: reduce ? 0 : 4 }}\n                              key={label(r, c)}\n                              transition={{ duration: 0.15 }}\n                            >\n                              {label(r, c)}\n                            </motion.span>\n                          </AnimatePresence>\n                        </motion.div>\n                      </div>\n                    );\n                  })}\n                </div>\n              );\n            })}\n          </div>\n        </div>\n\n        <div\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute inset-y-0 right-0 w-8 bg-linear-to-l from-card to-transparent transition-opacity duration-150\",\n            moreRight ? \"opacity-100\" : \"opacity-0\",\n          )}\n        />\n        <AnimatePresence>\n          {active ? (\n            <motion.div\n              animate={{ opacity: 1, scale: 1 }}\n              className={cn(\n                \"pointer-events-none absolute z-10 w-max max-w-[240px] rounded-lg border bg-popover px-2.5 py-2 text-popover-foreground text-xs shadow-md/5\",\n                tipAlign === \"center\" && \"-translate-x-1/2\",\n                tipAlign === \"end\" && \"-translate-x-full\",\n                !below && \"-translate-y-full\",\n              )}\n              exit={{ opacity: 0, transition: { duration: reduce ? 0 : 0.1 } }}\n              initial={{ opacity: 0, scale: reduce ? 1 : 0.96 }}\n              style={{\n                left: tip.x + (tipAlign === \"start\" ? -12 : tipAlign === \"end\" ? 12 : 0),\n                top: below ? tip.y + CELL_H + 6 : tip.y - 6,\n                originX: tipAlign === \"start\" ? 0 : tipAlign === \"end\" ? 1 : 0.5,\n                originY: below ? 0 : 1,\n              }}\n              transition={{ duration: reduce ? 0 : 0.14, ease: [0.23, 1, 0.32, 1] }}\n            >\n              <div className=\"font-medium\">\n                {active.r === cohorts.length ? \"All cohorts\" : `Joined week of ${cohorts[active.r].label}`}\n                <span className=\"font-normal text-muted-foreground\"> · {periodName} {active.c}</span>\n              </div>\n              <div className=\"mt-1 text-muted-foreground tabular-nums\">\n                {active.r === cohorts.length ? (\n                  <>\n                    <span className=\"text-foreground\">{(activeRate * 100).toFixed(1)}%</span> still active on average\n                  </>\n                ) : (\n                  <>\n                    <span className=\"text-foreground\">{num.format(cohorts[active.r].retained[active.c])}</span> of{\" \"}\n                    {num.format(cohorts[active.r].size)} active · {(activeRate * 100).toFixed(1)}%\n                  </>\n                )}\n              </div>\n              {vsAvg !== null && active.c > 0 ? (\n                <div\n                  className={cn(\n                    \"mt-0.5 tabular-nums\",\n                    vsAvg >= 2 ? \"text-success-foreground\" : vsAvg <= -2 ? \"text-destructive-foreground\" : \"text-muted-foreground\",\n                  )}\n                >\n                  {vsAvg >= 0 ? \"+\" : \"−\"}\n                  {Math.abs(vsAvg).toFixed(1)} pts vs average\n                </div>\n              ) : null}\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n        <span aria-live=\"polite\" className=\"sr-only\">\n          {announce}\n        </span>\n      </div>\n\n      <footer className=\"mt-4 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 text-muted-foreground text-xs\">\n        <span className=\"tabular-nums\">\n          {periodName} 4 retention{\" \"}\n          <span className=\"font-medium text-foreground\">{avg[4] !== undefined ? `${(avg[4] * 100).toFixed(1)}%` : \"–\"}</span>\n        </span>\n        <div aria-hidden className=\"flex items-center gap-1.5\">\n          <span>0%</span>\n          <span className=\"h-1.5 w-20 rounded-full\" style={{ background: `linear-gradient(to right, ${cellBg(0)}, ${cellBg(1)})` }} />\n          <span className=\"tabular-nums\">{Math.round(maxLater * 100)}%+</span>\n        </div>\n      </footer>\n    </section>\n  );\n}\n"
    }
  ]
}