Cohort Table
Retention by signup period, as a triangle. Cells are shaded by rate rather than by count, so a small cohort that retains well is not washed out by a large one that does not.
src/components/ui/cohort-table.tsxAbout
From the component's own source, where it was written.
A retention cohort grid — signup period down, periods since across.
Incomplete cohorts are marked rather than plotted. The newest cohort has not lived long enough to have a week-4 number, and rendering an empty cell as 0% makes retention look like it fell off a cliff. This is the single most common way a cohort chart lies.
Period 0 is always 100% by definition, so it is rendered as the cohort size instead — that cell is the only place the absolute number appears, and without it every percentage is unanchored.
Install
npx astralyx-ui add cohort-table
Usage
import { CohortTable } from '@/components/ui/cohort-table'
<CohortTable cohorts={cohorts} periods={7} />Composer
Change the props and the source below follows.
| Cohort | Size | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|---|
| Mar 2026 | 1,284 | 100% | 58% | 46% | 39% | 36% | 34% | 33% |
| Apr 2026 | 1,609 | 100% | 62% | 49% | 43% | 39% | 38% | · |
| May 2026 | 1,412 | 100% | 59% | 46% | 40% | 36% | · | · |
| Jun 2026 | 2,058 | 100% | 67% | 54% | 48% | · | · | · |
| Jul 2026 | 2,240 | 100% | 67% | 53% | · | · | · | · |
| Aug 2026 | 1,977 | 100% | 64% | · | · | · | · | · |
| Sep 2026 | 604 | 100% | · | · | · | · | · | · |
| Average | 100% | 63% | 50% | 42% | 37% | 36% | 33% |
A dot means the cohort has not reached that period yet — not zero retention. Averages count only cohorts that have.
<CohortTable cohorts={cohorts} periods={7} />Examples
Weekly retention
<CohortTable cohorts={cohorts} />API
Generated from the source, so it cannot fall behind the code. One table per exported component, then hooks, functions and types.
CohortTable
Extends ComponentProps<'div'>.
| Prop | Type | Default | Description |
|---|---|---|---|
| cohorts | Cohort[] | required | { label, size, values } where values[0] is the cohort period. Later periods may be missing — a cohort three months old has no month-six figure yet. |
| periods | number | — | Columns rendered. Rows shorter than this end where their data does. |
| periodLabel | (index: number) => ReactNode | DEFAULT_PERIOD_LABEL | For weeks, days, or whatever the cohort period actually is. |
| locale | string | 'en-GB' | — |
| cohortHeader | ReactNode | 'Cohort' | — |
| sizeHeader | ReactNode | 'Size' | — |
| averageLabel | ReactNode | 'Average' | Label on the averages row. |
| notReachedLabel | string | 'Not yet reached' | Accessible name for a period a cohort has not reached. |
| footnote | ReactNode | 'A dot means the cohort has not reached that period yet — not zero retention. Averages count only cohorts that have.' | Note under the table. Pass null to drop it. |
Types
Cohort
A retention cohort grid — signup period down, periods since across.
| Field | Type | Description |
|---|---|---|
| label | ReactNode | Period label — "Jan 2026", "Week 12". |
| size | number | People in the cohort at period 0. |
| values | (number | null)[] | Retained counts per period, index 0 being the cohort period. |
Behaviour
| Topic | Detail | Description |
|---|---|---|
| missing vs zero | null | A null renders as an empty cell, never as 0%. Drawing "not yet measured" as total churn is the classic cohort-chart lie. |
| shading | by rate | Each cell is a share of its own cohort size, so rows are comparable across wildly different cohort sizes. |