## When to use

A true pixel-grid heatmap loses the numeric precision that retention analysis needs — you want to see that W4 held at 54% AND scan the color band. A table with badge cells gives you both: precise numbers on one axis, color-coded buckets across the row. It also preserves accessibility (each number is a real table cell) and lets the same component render partial cohorts (null cells where the cohort hasn't reached that week yet).

## Default retention table

Each week-column uses the same `formatPct` renderer, which wraps the number in a `badge-ui` whose variant is keyed to three buckets: ≥60% success, 30–60% warning, <30% muted. Null values render blank so partial cohorts don't force fake zeros.

### Retention by signup week

% of users still active in week N

## With a legend

For first-time viewers, pair the table with a tiny legend in the card footer so the buckets are decoded. The legend itself is three `badge-ui`s — same component the cells use — keeping the visual vocabulary tight.

### Retention by signup week

Percent of the cohort still active N weeks after signup

## With headline stats

When the cohort table is the only chart in a section, prepend a stat pair for the most important numbers — W4 (early signal) and W12 (sustained retention). Stops users having to scan every row to answer "is this healthy?"

### Retention

New signups · rolling 6-week view

## The cell renderer

All three examples use the same `formatPct` function defined in the page's `setup.js`. The column-definition object references it via `render: formatPct`:

const formatPct = (v) => { if (v == null) return ''; const n = Math.round(v); const variant = n >= 60 ? 'success' : n >= 30 ? 'warning' : 'muted'; return `<badge-ui text='${n}%' variant='${variant}'></badge-ui>`; }; const columns = [ { key: 'week', label: 'Signup week' }, { key: 'size', label: 'Users' }, { key: 'w1', label: 'W1', render: formatPct }, { key: 'w2', label: 'W2', render: formatPct }, { key: 'w4', label: 'W4', render: formatPct }, { key: 'w8', label: 'W8', render: formatPct }, { key: 'w12', label: 'W12', render: formatPct }, ];

## Variants & knobs

- Bucket thresholds. 60/30 works for most SaaS cohorts. For transactional products (food delivery, e-commerce), bump to 40/20 — those products have lower week-N retention by nature.
- Null cells. Return `''` for unreached weeks, never a zero or dash. An empty cell reads as "not yet known"; zero reads as "churned."
- Column set. Canonical columns: Signup week · Users · W1 · W2 · W3 · W4 · W8 · W12. Drop W3 if space is tight; don't drop W1 or W4 (baseline + first truly revealing bucket).
- Number of rows. 6–8 cohorts is the sweet spot. More becomes noise; fewer loses the trend.

## Anti-patterns

- Don't use a continuous color ramp. Three buckets read faster than a gradient — users can scan for "red" rows without decoding shades.
- Don't left-align the percentages. Numbers belong right-aligned (or center via the badge) so magnitudes line up vertically.
- Don't include the current (incomplete) cohort. The top row should be the most-recent cohort whose W1 is complete; otherwise its zero-ish numbers distort the visual trend.

## Authoring rules

- Cohort axis (rows) = time of acquisition — typically week or month. The column axis = time since acquisition. Do not transpose this: readers expect cohorts as rows.
- Always color-code by retention rate, not absolute count. The heatmap's value is in visual comparison across cohorts; absolute numbers belong in tooltips, not cell backgrounds.
- Show at least 4 cohorts and 4 time periods for the diagonal comparison to be meaningful. A 2×2 heatmap adds no insight over a single number.
- Use `data-stream-src` for live cohort data — cohort heatmaps are expensive to compute; don't poll more frequently than the underlying aggregation window (usually daily).