Retention Cohort
card-ui table-ui badge-ui
Composition pattern: render a retention cohort "heatmap" using a regular table-ui with a badge-based cell renderer. Each cell is a badge-ui variant tinted by the retention bucket, so the table still sorts, paginates, and copies to clipboard like any other data table.
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-uis — 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).