{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "retention-1",
  "title": "Retention 1: cohort heatmap",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A cohort retention table as a heatmap: rows of people who started together, columns of later periods, each cell tinted by the share still active, with headline numbers, percent or people, a keyboard grid and a size-weighted average row.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/segmented-control.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/retention-1/retention-1.tsx",
      "content": "// Ballmac UI: Retention 1. https://ui.ballmac.com/blocks/retention-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Grid3x3, TrendingDown, TrendingUp } from \"lucide-react\"\n\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ballmac/segmented-control\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Retention1Cohort = {\n  id: string\n  /** \"Week of 7 September\". */\n  label: string\n  /** People who started in this cohort. */\n  size: number\n  /** Share still active in each period as 0 to 100, starting with period 0 (always 100). Recent cohorts have fewer entries. */\n  retention: number[]\n}\n\ntype Retention1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    title?: string\n    description?: string\n    cohorts?: Retention1Cohort[]\n    /** What one column is: \"Week\", \"Month\", \"Day\". */\n    period?: string\n    locale?: string\n    /** Whether cells show a share or a number of people at first. */\n    defaultMode?: \"percent\" | \"people\"\n  }\n\nconst sample: Retention1Cohort[] = [\n  { id: \"c1\", label: \"Week of 3 August\", size: 1420, retention: [100, 64, 51, 44, 40, 37, 35, 33, 32, 31] },\n  { id: \"c2\", label: \"Week of 10 August\", size: 1380, retention: [100, 66, 53, 46, 41, 38, 36, 34, 33] },\n  { id: \"c3\", label: \"Week of 17 August\", size: 1510, retention: [100, 61, 48, 41, 36, 33, 31, 30] },\n  { id: \"c4\", label: \"Week of 24 August\", size: 1465, retention: [100, 68, 56, 49, 45, 42, 40] },\n  { id: \"c5\", label: \"Week of 31 August\", size: 1590, retention: [100, 70, 58, 51, 47, 44] },\n  { id: \"c6\", label: \"Week of 7 September\", size: 1640, retention: [100, 72, 60, 53, 49] },\n  { id: \"c7\", label: \"Week of 14 September\", size: 1725, retention: [100, 71, 61, 55] },\n  { id: \"c8\", label: \"Week of 21 September\", size: 1810, retention: [100, 74, 63] },\n  { id: \"c9\", label: \"Week of 28 September\", size: 1760, retention: [100, 75] },\n  { id: \"c10\", label: \"Week of 5 October\", size: 1890, retention: [100] },\n]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-6\">\n      <div className=\"grid gap-4 sm:grid-cols-3\">\n        {[0, 1, 2].map((i) => (\n          <Skeleton key={i} className=\"h-24 rounded-2xl\" />\n        ))}\n      </div>\n      <Skeleton className=\"h-96 rounded-2xl\" />\n    </div>\n  )\n}\n\nconst tint = (v: number) => `color-mix(in oklab, var(--chart-1) ${Math.round(6 + (v / 100) * 52)}%, transparent)`\n\n/**\n * A cohort retention table as a heatmap: each row is a group of people who started together, each column a later period, each cell the share\n * still active, tinted by how high it is. Cells are a keyboard grid (arrows move, Home and End jump) that reads out the cell and how it compares with\n * the average for that period, and the three headline numbers above are worked out from the table.\n */\nfunction Retention1({\n  title = \"Weekly retention\",\n  description = \"How many people from each starting week came back, week by week.\",\n  cohorts = sample,\n  period = \"Week\",\n  locale = \"en\",\n  defaultMode = \"percent\",\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Retention1Props) {\n  const id = React.useId()\n  const list = state === \"empty\" ? [] : cohorts\n  const [mode, setMode] = React.useState<\"percent\" | \"people\">(defaultMode)\n  const [at, setAt] = React.useState<[number, number]>([0, 1])\n  const [focused, setFocused] = React.useState(false)\n  const gridRef = React.useRef<HTMLDivElement>(null)\n  const n = (x: number) => new Intl.NumberFormat(locale).format(x)\n\n  const cols = Math.max(0, ...list.map((c) => c.retention.length))\n  // The average of a period only counts cohorts old enough to have reached it, weighted by size.\n  const avg = Array.from({ length: cols }, (_, p) => {\n    const has = list.filter((c) => c.retention[p] !== undefined)\n    const size = has.reduce((s, c) => s + c.size, 0)\n    return size ? has.reduce((s, c) => s + c.retention[p]! * c.size, 0) / size : 0\n  })\n  const best = list.reduce<Retention1Cohort | null>((b, c) => (c.retention[4] !== undefined && (!b || c.retention[4]! > b.retention[4]!) ? c : b), null)\n  const recent = list.filter((c) => c.retention[1] !== undefined).slice(-3)\n  const oldest = list.filter((c) => c.retention[1] !== undefined).slice(0, 3)\n  const mean = (cs: Retention1Cohort[]) => (cs.length ? cs.reduce((s, c) => s + c.retention[1]!, 0) / cs.length : 0)\n  const trend = recent.length && oldest.length ? mean(recent) - mean(oldest) : 0\n\n  const cell = list[at[0]]?.retention[at[1]]\n  const cellCohort = list[at[0]]\n  const diff = cell !== undefined ? cell - avg[at[1]]! : 0\n\n  function key(e: React.KeyboardEvent) {\n    const [r, c] = at\n    const reach = (rr: number) => list[rr]?.retention.length ?? 0\n    let nr = r\n    let nc = c\n    if (e.key === \"ArrowRight\") nc = Math.min(reach(r) - 1, c + 1)\n    else if (e.key === \"ArrowLeft\") nc = Math.max(0, c - 1)\n    else if (e.key === \"ArrowDown\") nr = Math.min(list.length - 1, r + 1)\n    else if (e.key === \"ArrowUp\") nr = Math.max(0, r - 1)\n    else if (e.key === \"Home\") nc = 0\n    else if (e.key === \"End\") nc = reach(r) - 1\n    else return\n    e.preventDefault()\n    nc = Math.min(nc, reach(nr) - 1)\n    setAt([nr, nc])\n    requestAnimationFrame(() => gridRef.current?.querySelector<HTMLElement>(`[data-cell=\"${nr}-${nc}\"]`)?.focus())\n  }\n\n  const content = !list.length || !cols ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <Grid3x3 className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No cohorts yet\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty\">{emptyState?.description ?? \"Once people have started and a full period has passed, their retention appears here.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <div className=\"grid grid-cols-1 gap-6\">\n      <div className=\"grid gap-4 sm:grid-cols-3\">\n        {[\n          { label: `${period} 1 retention`, value: `${avg[1] !== undefined ? avg[1].toFixed(0) : \"–\"}%`, note: \"Came back the next period\" },\n          { label: `${period} 4 retention`, value: avg[4] !== undefined ? `${avg[4].toFixed(0)}%` : \"–\", note: best ? `Best: ${best.label}, ${best.retention[4]}%` : \"Not enough history yet\" },\n          { label: \"Trend\", value: `${trend >= 0 ? \"+\" : \"\"}${trend.toFixed(1)} pts`, note: `${period} 1, newest three cohorts against the oldest three`, icon: trend >= 0 ? TrendingUp : TrendingDown },\n        ].map((s) => (\n          <div key={s.label} className=\"bg-card rounded-2xl border p-5\">\n            <p className=\"text-muted-foreground flex items-center gap-1.5 text-sm\">\n              {s.icon ? <s.icon aria-hidden=\"true\" className={cn(\"size-4\", trend >= 0 ? \"text-chart-2\" : \"text-destructive\")} /> : null}\n              {s.label}\n            </p>\n            <p className=\"mt-1 text-3xl font-semibold tracking-[-0.04em] tabular-nums\">{s.value}</p>\n            <p className=\"text-muted-foreground mt-1 text-xs text-pretty\">{s.note}</p>\n          </div>\n        ))}\n      </div>\n\n      <div className=\"bg-card min-w-0 rounded-2xl border\">\n        <div className=\"flex flex-wrap items-center justify-between gap-3 border-b px-5 py-3.5\">\n          <p className=\"text-sm font-medium\">Share of each cohort still active</p>\n          <SegmentedControl value={mode} onValueChange={(v) => setMode(v as \"percent\" | \"people\")} aria-label=\"Show\" size=\"sm\">\n            <SegmentedControlItem value=\"percent\">Percent</SegmentedControlItem>\n            <SegmentedControlItem value=\"people\">People</SegmentedControlItem>\n          </SegmentedControl>\n        </div>\n        <div className=\"overflow-x-auto\">\n          <div ref={gridRef} role=\"grid\" aria-label=\"Retention by starting cohort and period\" aria-colcount={cols + 1} aria-rowcount={list.length + 2} onKeyDown={key} className=\"w-max min-w-full p-4 sm:p-5\" dir=\"ltr\">\n            <div role=\"row\" className=\"mb-1.5 flex items-end gap-1.5\">\n              <div role=\"columnheader\" className=\"text-muted-foreground w-44 shrink-0 text-xs font-medium sm:w-52\">\n                Cohort\n              </div>\n              {Array.from({ length: cols }, (_, p) => (\n                <div key={p} role=\"columnheader\" className=\"text-muted-foreground w-14 shrink-0 text-center text-xs font-medium sm:w-16\">\n                  {period} {p}\n                </div>\n              ))}\n            </div>\n            {list.map((c, r) => (\n              <div key={c.id} role=\"row\" className=\"mb-1.5 flex items-center gap-1.5\">\n                <div role=\"rowheader\" className=\"w-44 shrink-0 pe-2 sm:w-52\">\n                  <p className=\"truncate text-sm font-medium\">{c.label}</p>\n                  <p className=\"text-muted-foreground text-xs tabular-nums\">{n(c.size)} people</p>\n                </div>\n                {Array.from({ length: cols }, (_, p) => {\n                  const v = c.retention[p]\n                  if (v === undefined) return <div key={p} role=\"gridcell\" aria-label=\"Not yet\" className=\"h-11 w-14 shrink-0 sm:w-16\" />\n                  const sel = at[0] === r && at[1] === p\n                  return (\n                    <div\n                      key={p}\n                      role=\"gridcell\"\n                      data-cell={`${r}-${p}`}\n                      tabIndex={sel ? 0 : -1}\n                      aria-selected={sel}\n                      aria-label={`${c.label}, ${period.toLowerCase()} ${p}: ${v}% still active, ${n(Math.round((c.size * v) / 100))} people`}\n                      onClick={() => setAt([r, p])}\n                      onFocus={() => { setAt([r, p]); setFocused(true) }}\n                      onBlur={() => setFocused(false)}\n                      className={cn(\"focus-visible:ring-ring/60 flex h-11 w-14 shrink-0 cursor-pointer items-center justify-center rounded-lg text-sm font-medium tabular-nums outline-none transition-[box-shadow] focus-visible:ring-[3px] motion-reduce:transition-none sm:w-16\", sel && \"ring-foreground ring-2\")}\n                      style={{ background: tint(v) }}\n                    >\n                      {mode === \"percent\" ? `${v}%` : n(Math.round((c.size * v) / 100))}\n                    </div>\n                  )\n                })}\n              </div>\n            ))}\n            <div role=\"row\" className=\"mt-3 flex items-center gap-1.5 border-t pt-3\">\n              <div role=\"rowheader\" className=\"w-44 shrink-0 pe-2 text-sm font-semibold sm:w-52\">\n                Average\n              </div>\n              {avg.map((v, p) => (\n                <div key={p} role=\"gridcell\" className=\"w-14 shrink-0 text-center text-sm font-semibold tabular-nums sm:w-16\">\n                  {v.toFixed(0)}%\n                </div>\n              ))}\n            </div>\n          </div>\n        </div>\n        <div className=\"flex flex-wrap items-center justify-between gap-3 border-t px-5 py-3.5 text-xs\">\n          <p aria-live=\"polite\" className=\"text-muted-foreground text-sm text-pretty\">\n            {cell !== undefined && cellCohort ? (\n              <>\n                <span className=\"text-foreground font-medium\">{cellCohort.label}</span>, {period.toLowerCase()} {at[1]}: {cell}% still active ({n(Math.round((cellCohort.size * cell) / 100))} of {n(cellCohort.size)}).\n                {at[1] > 0 ? ` That is ${Math.abs(diff).toFixed(0)} ${Math.abs(diff).toFixed(0) === \"1\" ? \"point\" : \"points\"} ${diff >= 0 ? \"above\" : \"below\"} the average.` : \"\"}\n              </>\n            ) : (\n              \"Choose a cell to compare it with the average.\"\n            )}\n          </p>\n          <div aria-hidden=\"true\" className=\"text-muted-foreground flex items-center gap-2\">\n            0%\n            <span className=\"h-2 w-28 rounded-full\" style={{ background: \"linear-gradient(90deg, color-mix(in oklab, var(--chart-1) 6%, transparent), color-mix(in oklab, var(--chart-1) 58%, transparent))\" }} />\n            100%\n          </div>\n        </div>\n      </div>\n      <p className=\"sr-only\">{focused ? \"Use the arrow keys to move between cells.\" : \"\"}</p>\n    </div>\n  )\n\n  return (\n    <section data-slot=\"retention-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-2 max-w-2xl text-pretty\">{description}</p>\n      </header>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading retention\" loading={<Skel />} error={{ title: errorState?.title ?? \"Retention could not be loaded\", description: errorState?.description ?? \"Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Retention1, type Retention1Props, type Retention1Cohort }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/retention-1/retention-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "retention",
      "cohorts",
      "heatmap",
      "churn",
      "analytics",
      "engagement",
      "growth",
      "dashboard",
      "cohort analysis"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A cohort retention heatmap. <Retention1 cohorts={[{ id, label: 'Week of 3 August', size: 1420, retention: [100, 64, 51, ...] }]} period='Week' locale />. retention is the percent still active in each period starting at period 0; newer cohorts have fewer entries. The average row weights each period by cohort size and only counts cohorts old enough to reach it. Headline numbers (period 1, period 4, trend) are worked out from the table.",
      "whenToUse": [
        "Product analytics: do people come back?",
        "Comparing the quality of signups week by week"
      ],
      "whenNotToUse": [
        "A step-by-step conversion path (use funnel-1)",
        "A trend line of one metric (use a chart)"
      ],
      "composesWith": [
        "funnel-1",
        "experiments-1",
        "app-shell-1"
      ],
      "a11y": [
        {
          "keys": "Tab, then arrow keys, Home and End",
          "action": "The table is a grid with one tab stop; each cell is read as the cohort, the period, the percent and the number of people"
        },
        {
          "keys": "Selection",
          "action": "A sentence under the table says how the chosen cell compares with the average, announced politely"
        },
        {
          "keys": "Color",
          "action": "Every cell shows its number; the tint is only an extra cue"
        }
      ],
      "customization": [
        "period: Week, Month or Day names the columns",
        "Percent or People switch shows counts instead of shares",
        "The table scrolls sideways on a phone and keeps its left-to-right order in every language, like a chart"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "retention-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton stat cards and table under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No cohorts yet",
        "example": "retention-1-empty",
        "trigger": "state=\"empty\" (or cohorts={[]})",
        "note": "Says retention appears once people have started and a full period has passed; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "retention-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry)."
      }
    ],
    "examples": [
      "retention-1-demo",
      "retention-1-monthly",
      "retention-1-loading",
      "retention-1-empty",
      "retention-1-error"
    ],
    "url": "https://ui.ballmac.com/components/retention-1"
  },
  "categories": [
    "blocks",
    "retention"
  ],
  "type": "registry:block"
}