{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cohort-grid",
  "type": "registry:ui",
  "title": "Cohort grid",
  "description": "Retention by signup week as a real table: a row per cohort, a column per period since signup, and in every cell the number in mono with a dot before it in one data hue's four steps. Younger cohorts have fewer periods and their empty cells draw nothing. An optional row sums up every cohort, weighted by size. The cohort column stays put while the periods scroll sideways inside the grid's own box. Static, with a legend from less to more.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/cohort-grid.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/cohort-grid 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Cohort grid: retention by signup week as a real table. One row per cohort, one column per period since signup,\n * and in every cell the number itself, with a dot in front of it in one data hue's four steps (R7), so the eye\n * still finds where retention holds. The numbers are the point; the dots are the Heatmap's scale beside them.\n *\n * - Columns: the cohort's name (small, `--litt-fg`, at the start), its size (caption, `--litt-fg-subtle`, tabular, at the end,\n *   headed \"Users\"), then a cell per period: an 8px dot just before the value (caption at 400, `--litt-fg`,\n *   tabular, end-aligned). Every number keeps the width of the grid's longest, so the dots line up in a column\n *   and the figures do too. Both inks pass 4.5:1, and no value is set quieter for being low: color is never the\n *   only signal.\n * - Color (components.md, Heatmap): zero is an empty dot at fg 8%. The four steps are the hue's mark tone over\n *   transparent at 30, 55, 80 and 100%. The default steps are the quartiles of the non-zero values, as in the\n *   Heatmap. A missing or `null` value (a cohort too young for that period) is an empty cell: no dot, no number,\n *   and \"no data\" for a screen reader. The legend under the table is \"Less\", the five levels, \"More\", hidden from\n *   screen readers because the numbers already say it.\n * - Average: `average` adds a last row in a <tfoot>, over a `--litt-line-strong` hairline. For shares it is \"All\n *   cohorts\", the share of every user who has reached the period (the cohorts weighted by size), with the total\n *   size. For counts it is \"Average cohort\", the mean count of the cohorts that have reached the period, with the\n *   mean size, so it stays on the cells' scale and takes its dots from the same steps.\n * - Look: the Table's. A hairline box at the surface radius filled surface-1, rows on 1px `--litt-line`, 44px rows,\n *   the header row at the caption size and the label weight in `--litt-fg-subtle`, sentence case. Period cells are\n *   compact, about 64px. No hover: there is nothing to pick.\n * - Fit: the table keeps the width its content needs and scrolls sideways inside its own box, so the page never\n *   does. The cohort column is sticky at the start on the box's own surface, and a hairline appears at its end\n *   once the periods have scrolled under it. When there is something to scroll, the box becomes a focusable group\n *   named by the caption (the Table's choice: a group, so several grids on a page do not repeat a landmark), so\n *   the arrow keys scroll it. When there is not, it adds no tab stop.\n * - Semantics: a native <table> with an sr-only <caption> (the label), `th scope=\"col\"` headers and a `th\n *   scope=\"row\"` per cohort, so a screen reader announces \"12 Oct, Week 3, 41%\". The caption is hidden because the\n *   card or section around the grid names it with its own heading, and a visible caption would scroll with the\n *   table. No roving tabindex and no tooltip: every number is already on screen.\n * - Motion: none. Static, like the Meter and the Breakdown.\n */\nimport { useEffect, useId, useRef, useState } from \"react\";\n\n/** The five data hues, in their fixed order (R7). */\nexport type CohortHue = \"blue\" | \"amber\" | \"teal\" | \"violet\" | \"rose\";\n\n/** One cohort: the people who signed up in one period, and how many of them were still active in each period after. */\nexport type Cohort = {\n  /** When the cohort signed up, in a few characters: \"12 Oct\", \"Aug 2026\". It names the row and every value in it. */\n  label: string;\n  /** How many people the cohort started with. Shown in the size column, and the weight of the cohort in the average row. */\n  size: number;\n  /** One value per period since signup, the first period first: the share still active (0 to 100), or a count with `valueType=\"count\"`. A period the cohort has not reached yet is `null`, or leave it off the end. */\n  values: (number | null)[];\n};\n\nexport type CohortGridProps = {\n  /** The grid's name: \"Retention by signup week\". It is the table's hidden caption and names the scroll box. */\n  label: string;\n  /** The cohorts, oldest first, so the youngest (with the fewest periods) sit at the bottom and the grid reads as a triangle. */\n  cohorts: Cohort[];\n  /** One header per period column: [\"Month 0\", \"Month 1\"]. Leave it out and they are `periodLabel` and the period's number. */\n  periods?: string[];\n  /** The word before each period's number when `periods` is left out: \"Month\" gives \"Month 0\", \"Month 1\". */\n  periodLabel?: string;\n  /** The header of the first column, over the cohorts' names. */\n  cohortLabel?: string;\n  /** The header of the size column. */\n  sizeLabel?: string;\n  /** Whether the values are shares of the cohort (0 to 100, written \"42%\") or counts of people (written \"1,240\"). */\n  valueType?: \"percent\" | \"count\";\n  /** The one data hue the dots are made of. Pick it for what the grid is about and keep it the same for the same thing across a view. */\n  hue?: CohortHue;\n  /** Where each of the four dot levels starts, lowest first. Zero is always the empty dot, and a value under the first step is still the lightest level. The default is the quartiles of the non-zero values. */\n  steps?: [number, number, number, number];\n  /** Turns a value into the text in its cell: `(v) => v.toFixed(1) + \"%\"`. The default rounds a share to a whole percent and groups a count's thousands. */\n  formatValue?: (value: number) => string;\n  /** Adds a last row that sums up every cohort: \"All cohorts\" for shares (weighted by size), \"Average cohort\" for counts. */\n  average?: boolean;\n  /** Extra classes for the grid's outer box, such as a max width. */\n  className?: string;\n};\n\n/* ----------------------------------------------------------------- tokens and helpers */\n\n/** An empty dot: the foreground at 8%. */\nconst EMPTY = \"bg-[color-mix(in_oklab,var(--litt-fg)_8%,transparent)]\";\n\n/** The four steps of each hue as classes, 30, 55, 80 and 100% of the mark tone over transparent. Literal strings, so Tailwind sees them. */\nconst HUE: Record<CohortHue, readonly [string, string, string, string]> = {\n  blue: [\n    \"bg-[color-mix(in_oklab,var(--litt-data-blue)_30%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-blue)_55%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-blue)_80%,transparent)]\",\n    \"bg-litt-data-blue\",\n  ],\n  amber: [\n    \"bg-[color-mix(in_oklab,var(--litt-data-amber)_30%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-amber)_55%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-amber)_80%,transparent)]\",\n    \"bg-litt-data-amber\",\n  ],\n  teal: [\n    \"bg-[color-mix(in_oklab,var(--litt-data-teal)_30%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-teal)_55%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-teal)_80%,transparent)]\",\n    \"bg-litt-data-teal\",\n  ],\n  violet: [\n    \"bg-[color-mix(in_oklab,var(--litt-data-violet)_30%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-violet)_55%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-violet)_80%,transparent)]\",\n    \"bg-litt-data-violet\",\n  ],\n  rose: [\n    \"bg-[color-mix(in_oklab,var(--litt-data-rose)_30%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-rose)_55%,transparent)]\",\n    \"bg-[color-mix(in_oklab,var(--litt-data-rose)_80%,transparent)]\",\n    \"bg-litt-data-rose\",\n  ],\n};\n\nconst groupDigits = (n: number) => n.toLocaleString(\"en-GB\", { maximumFractionDigits: 0 });\nconst wholePercent = (n: number) => `${Math.round(n)}%`;\nconst isValue = (v: number | null | undefined): v is number => typeof v === \"number\" && Number.isFinite(v);\n\n/** The value at a fraction of the sorted list, interpolating between neighbours. */\nfunction quantile(sorted: number[], p: number) {\n  if (sorted.length === 0) return 0;\n  const at = (sorted.length - 1) * p;\n  const lo = Math.floor(at);\n  const hi = Math.ceil(at);\n  return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * (at - lo);\n}\n\n/** The quartiles of the non-zero values: the level 1 start, then the first, second and third quartile. The Heatmap's scale. */\nfunction quartileSteps(cohorts: Cohort[]): [number, number, number, number] {\n  const values = cohorts.flatMap((c) => c.values).filter((v): v is number => isValue(v) && v > 0);\n  if (values.length === 0) return [1, 2, 3, 4];\n  const sorted = values.sort((a, b) => a - b);\n  return [sorted[0]!, quantile(sorted, 0.25), quantile(sorted, 0.5), quantile(sorted, 0.75)];\n}\n\n/** 0 for an empty dot, then 1 to 4. A non-zero value under the first step is still level 1. */\nfunction levelOf(value: number, steps: readonly number[]) {\n  if (!(value > 0)) return 0;\n  let level = 1;\n  for (let k = 1; k < 4; k++) if (value >= steps[k]!) level = k + 1;\n  return level;\n}\n\n/** The summary row: for shares, every user who reached the period (cohorts weighted by size); for counts, the mean cohort. */\nfunction averageRow(cohorts: Cohort[], periods: number, valueType: \"percent\" | \"count\"): Cohort {\n  const values = Array.from({ length: periods }, (_, p) => {\n    const reached = cohorts.filter((c) => isValue(c.values[p]));\n    if (reached.length === 0) return null;\n    if (valueType === \"count\") return reached.reduce((s, c) => s + c.values[p]!, 0) / reached.length;\n    const people = reached.reduce((s, c) => s + Math.max(0, c.size), 0);\n    return people > 0 ? reached.reduce((s, c) => s + c.values[p]! * Math.max(0, c.size), 0) / people : null;\n  });\n  const total = cohorts.reduce((s, c) => s + Math.max(0, c.size), 0);\n  return valueType === \"count\"\n    ? { label: \"Average cohort\", size: cohorts.length ? total / cohorts.length : 0, values }\n    : { label: \"All cohorts\", size: total, values };\n}\n\n/* ----------------------------------------------------------------- cells */\n\nconst headCell = \"h-litt-control-md border-b border-litt-line in-[[data-material=flat]]:border-transparent px-3 py-3 align-middle text-litt-caption font-litt-label whitespace-nowrap text-litt-fg-subtle litt-legend\";\nconst bodyCell = \"h-litt-control-md px-3 py-3 align-middle whitespace-nowrap\";\n/** The sticky cohort column: the box's own surface under it, and a hairline at its end once the periods scroll under it. */\nconst sticky =\n  \"sticky left-0 z-10 bg-litt-surface-1 pl-4 text-left after:pointer-events-none after:absolute after:inset-y-0 after:right-0 after:w-px after:bg-litt-line after:opacity-0 group-data-[scrolled=true]:after:opacity-100\";\nconst figures = \"text-litt-caption font-normal tabular-nums\";\n\nfunction ValueCell({ value, fill, text, line, chars }: { value: number | null | undefined; fill: string; text: string; line: string; chars: number }) {\n  if (!isValue(value)) {\n    return (\n      <td className={bodyCell + \" \" + line}>\n        <span className=\"sr-only\">no data</span>\n      </td>\n    );\n  }\n  return (\n    <td className={bodyCell + \" \" + line}>\n      {/* The dot sits just before its number, and the number keeps the width of the grid's longest, so the dots line up too. */}\n      <span className=\"flex items-center justify-end gap-2\">\n        <span aria-hidden=\"true\" className={\"size-2 shrink-0 rounded-litt-pill \" + fill} />\n        <span className={figures + \" text-right text-litt-fg\"} style={{ minWidth: `${chars}ch` }}>\n          {text}\n        </span>\n      </span>\n    </td>\n  );\n}\n\n/* ----------------------------------------------------------------- the grid */\n\n/** Retention by cohort as a table: a row per cohort, a cell per period with its number and a dot in one hue's four steps. Static. */\nexport function CohortGrid({\n  label,\n  cohorts,\n  periods,\n  periodLabel = \"Week\",\n  cohortLabel = \"Cohort\",\n  sizeLabel = \"Users\",\n  valueType = \"percent\",\n  hue = \"teal\",\n  steps,\n  formatValue,\n  average = false,\n  className = \"\",\n}: CohortGridProps) {\n  const captionId = useId();\n  const region = useRef<HTMLDivElement>(null);\n  const [scrolls, setScrolls] = useState(false);\n  const [scrolled, setScrolled] = useState(false);\n\n  const fmt = formatValue ?? (valueType === \"count\" ? groupDigits : wholePercent);\n  const count = Math.max(periods?.length ?? 0, ...cohorts.map((c) => c.values.length));\n  const headers = Array.from({ length: count }, (_, p) => periods?.[p] ?? `${periodLabel} ${p}`);\n  const scale = steps ?? quartileSteps(cohorts);\n  const fillOf = (v: number) => {\n    const level = levelOf(v, scale);\n    return level === 0 ? EMPTY : HUE[hue][level - 1]!;\n  };\n  const summary = average && cohorts.length > 0 ? averageRow(cohorts, count, valueType) : null;\n  /* The longest value as written, in tabular figures, so every number takes the same width and the dots form columns. */\n  const chars = Math.max(\n    1,\n    ...[...cohorts, ...(summary ? [summary] : [])].flatMap((c) => c.values.filter(isValue).map((v) => fmt(v).length)),\n  );\n\n  // The box is focusable only while there is something to scroll, and the sticky column's edge shows once it has scrolled.\n  useEffect(() => {\n    const el = region.current;\n    if (!el) return;\n    const measure = () => {\n      setScrolls(el.scrollWidth > el.clientWidth + 1);\n      setScrolled(el.scrollLeft > 1);\n    };\n    measure();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    if (el.firstElementChild) observer.observe(el.firstElementChild);\n    return () => observer.disconnect();\n  }, []);\n\n  const row = (cohort: Cohort, line: string, key: string) => (\n    <tr key={key}>\n      <th scope=\"row\" className={bodyCell + \" \" + sticky + \" \" + line + \" text-litt-small font-normal text-litt-fg\"}>\n        {cohort.label}\n      </th>\n      <td className={bodyCell + \" \" + line + \" text-right \" + figures + \" text-litt-fg-subtle\"}>{groupDigits(cohort.size)}</td>\n      {headers.map((_, p) => {\n        const v = cohort.values[p];\n        const end = p === headers.length - 1 ? \" pr-4\" : \"\";\n        return <ValueCell key={p} value={v} fill={isValue(v) ? fillOf(v) : \"\"} text={isValue(v) ? fmt(v) : \"\"} line={line + end} chars={chars} />;\n      })}\n    </tr>\n  );\n\n  return (\n    <div className={\"grid min-w-0 grid-cols-1 gap-3 \" + className}>\n      <div\n        ref={region}\n        onScroll={(e) => setScrolled(e.currentTarget.scrollLeft > 1)}\n        {...(scrolls ? { role: \"group\", \"aria-labelledby\": captionId, tabIndex: 0 } : {})}\n        data-scrolled={scrolled}\n        className={\n          \"group relative w-full overflow-x-auto rounded-litt-surface border border-litt-line in-[[data-material=flat]]:border-transparent bg-litt-surface-1 \" +\n          \"[scrollbar-color:var(--litt-line-strong)_transparent] [scrollbar-width:thin] \" +\n          \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-litt-focus\"\n        }\n      >\n        <table className=\"w-full min-w-max border-separate border-spacing-0 text-left text-litt-small\">\n          <caption id={captionId} className=\"sr-only\">\n            {label}\n          </caption>\n          <thead>\n            <tr>\n              <th scope=\"col\" className={headCell + \" \" + sticky}>\n                {cohortLabel}\n              </th>\n              <th scope=\"col\" className={headCell + \" text-right\"}>\n                {sizeLabel}\n              </th>\n              {headers.map((h, p) => (\n                <th key={p} scope=\"col\" className={headCell + \" text-right\" + (p === headers.length - 1 ? \" pr-4\" : \"\")}>\n                  {h}\n                </th>\n              ))}\n            </tr>\n          </thead>\n          <tbody>\n            {cohorts.map((c, i) => row(c, i === cohorts.length - 1 ? \"\" : \"border-b border-litt-line in-[[data-material=flat]]:border-transparent\", `${i}-${c.label}`))}\n          </tbody>\n          {summary && <tfoot>{row(summary, \"border-t border-litt-line-strong in-[[data-material=flat]]:border-transparent\", \"summary\")}</tfoot>}\n        </table>\n      </div>\n      <Legend hue={hue} />\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- legend */\n\n/** \"Less\", the five levels, \"More\", under the table at its end. Hidden from screen readers: the numbers carry it. */\nfunction Legend({ hue }: { hue: CohortHue }) {\n  return (\n    <div aria-hidden=\"true\" className={\"flex items-center gap-2 justify-self-end whitespace-nowrap text-litt-fg-subtle \" + figures}>\n      <span>Less</span>\n      <span className=\"flex items-center gap-1\">\n        {[EMPTY, ...HUE[hue]].map((fill, i) => (\n          <span key={i} className={\"size-2 rounded-litt-pill \" + fill} />\n        ))}\n      </span>\n      <span>More</span>\n    </div>\n  );\n}\n",
      "target": "components/litt/cohort-grid.tsx"
    }
  ],
  "docs": "Import CohortGrid from components/litt/cohort-grid. Pass a label and cohorts as { label, size, values }, oldest first. Each value is the share still active (0 to 100), or a count with valueType=\"count\", and a missing or null value is a period the cohort has not reached. Set periodLabel=\"Month\" or pass periods for other headers. Add average for an All cohorts row and pick a hue.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
