Cohort grid A triangle, not a rectangle. Later cohorts have had less time, so the bottom-right is empty by construction and filling it with zeros invents a collapse that did not happen. Tokens this needs: --background, --foreground, --muted-foreground, --border, --scale-seq-1, --scale-seq-2, --scale-seq-3, --scale-seq-4, --scale-seq-5 The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // CohortGrid.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { cn } from "@/lib/utils"; /** One row. `size` is required: a percentage in a row of 80 and one in a row * of 8,000 look identical and are not comparable, so the count sits beside * the label. */ export type Cohort = { label: string; size: number; /** Share still active, one entry per period since joining, index 0 the * joining period. The array is as long as the cohort's history and no * longer: the triangle comes from that, never from padding. */ retention: number[]; }; export type Period = "day" | "week" | "month"; /** Full class names, because Tailwind only ships the ones it can see. */ const FILL = ["bg-scale-seq-1", "bg-scale-seq-2", "bg-scale-seq-3", "bg-scale-seq-4", "bg-scale-seq-5"]; export function CohortGrid({ cohorts, period, retained, thresholds, minSize = 0, onSelect }: { cohorts: Cohort[]; period: Period; /** What counts as active in a period, in words. It carries the whole * result, so it is printed under the grid rather than left in a query. */ retained: string; /** Lower bound of each colour step, ascending, five of them. The range is * the caller's choice because it decides whether a real difference shows. */ thresholds: [number, number, number, number, number]; /** Cohorts smaller than this show their size and no percentages. */ minSize?: number; onSelect?: (cohort: Cohort, periodIndex: number, value: number) => void; }) { const columns = Math.max(...cohorts.map((c) => c.retention.length)); const [col, setCol] = useState(null); const step = (v: number) => thresholds.filter((t) => v >= t).length - 1; return (
setCol(null)}> ))} {cohorts.map((c, r) => { const thin = c.size < minSize; const newest = r === cohorts.length - 1; return ( {c.retention.map((v, i) => ( ))} {/* No cell at all past the cohort's history. A zero here would draw a cliff nobody fell off. */} {Array.from({ length: columns - c.retention.length }, (_, i) => ); })}
{Array.from({ length: columns }, (_, i) => ( {period[0]}{i}
{c.label} {c.size.toLocaleString("en-GB")} )}

Active = {retained}, per {period} since joining. {minSize > 0 && ` Cohorts under ${minSize} show no percentages.`}

); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { CohortGrid, type Cohort } from "./CohortGrid"; /** Eight monthly signup cohorts followed by week. The May shift from 62 to * 64 at w1, and from 48 to 52 at w2, is the thing a column shows and an * average hides. */ const COHORTS: Cohort[] = [ { label: "Jan", size: 1_180, retention: [100, 62, 48, 41, 37, 35, 34, 34] }, { label: "Feb", size: 1_240, retention: [100, 62, 48, 41, 37, 35, 34] }, { label: "Mar", size: 1_310, retention: [100, 62, 48, 41, 37, 35] }, { label: "Apr", size: 1_270, retention: [100, 62, 48, 41, 37] }, { label: "May", size: 1_420, retention: [100, 64, 52, 49] }, { label: "Jun", size: 1_390, retention: [100, 64, 52] }, { label: "Jul", size: 1_460, retention: [100, 64] }, { label: "Aug", size: 1_510, retention: [100] }, ]; export default function Demo() { return ( {}} /> ); }