Skip to content
KONIGI

Dashboards / Visual representation / Cohort grid

4 of 22

Cohort grid

Retention differs by when people arrived; the viewer needs cohorts side by side over time.

Updated September 10, 2026

Problem

Retention is 34%. That number is compatible with a product that is steadily improving and one that is steadily rotting, because it averages together people who joined two years ago with people who joined last week.

Solution

Group people by when they arrived, then follow each group forward. One row per cohort, one column per period since joining, each cell holding the share still active. Colour the cells so the grid reads as a shape.

The layout produces a triangle, and the triangle is what makes it work. Older cohorts have more columns filled because more time has passed; the newest cohort has only its first cell. Reading is two-directional and each direction answers a different question.

Across a row is the decay curve for one cohort: how fast that particular group fell away, and whether it flattened into a stable base or kept dropping.

Down a column is the comparison that actually matters, and it is the reason this beats a line chart. Column three holds every cohort’s week-three retention, which means product changes become visible: if the last four cohorts have a better week three than the four before them, something you shipped worked. A single averaged retention number cannot show that, because it mixes cohorts at different ages.

The shape people look for is a flattening curve. Retention that declines and then plateaus means a real base of returning users. Retention that keeps declining through every column means the product is a leaky bucket regardless of how good acquisition looks.

Use when

Usage is repeat-visit by nature, the population is large enough for percentages to be stable, and you need to know whether changes are working rather than only how things stand.

Don’t use when

The product is genuinely one-and-done, where retention is the wrong question. Also avoid it with small cohorts: a row of forty people produces percentages that swing on a handful of individuals and read as signal.

Trade-offs

Cohort grids are dense and take teaching. Someone seeing one for the first time reads the triangle as missing data rather than as elapsed time. The newest cohorts, which are the ones you most want to judge, have the fewest cells and the least evidence, so the grid is structurally worst at answering the most urgent question. Cohort size varies down the rows, so a percentage in a row of 80 and one in a row of 8,000 look identical and are not comparable. And the definition of “active” carries the whole result while sitting nowhere on the chart.

Checklist

  • What defines the cohort—signup date, first purchase, first meaningful action?
  • What counts as retained in a period, and is that definition visible?
  • Are cohort sizes shown alongside the percentages?
  • Is there a minimum cohort size below which cells are suppressed?
  • Are period buckets days, weeks or months, and do they match the product’s natural rhythm?
  • Does the colour scale run over a range that makes real differences visible?
  • Can the viewer read down a column easily, or does the layout only support rows?
  • Does anything mark when a significant product change shipped?
  • Is the newest cohort visually distinguished as incomplete?
  • Would the same data as a set of decay curves communicate better to this audience?

Compare

Amplitude and Mixpanel treat the grid as interactive analysis: cohort definition, retention event and period granularity are all controls, and the grid recomputes, which is what makes it a tool rather than a report. Looker and similar BI tools express it as a pivot with conditional formatting, so the analysis lives in SQL and the grid is presentation—flexible, and easy to build subtly wrong. Grafana has no cohort concept; the nearest thing is a heatmap over a query someone shaped into cohort-by-period, which works and gives up the row and column semantics that make the pattern readable. GitHub’s contribution graph is worth naming as the cousin that made this cell-grid reading habit mainstream, even though it plots calendar time rather than cohort age.

Funnel is the same drop-off question compressed into one sequence with time removed. Calendar heatmap uses the same cell-grid encoding against calendar time. Heatmap is the general form. Compare periods is the coarser way to ask whether things are improving. Sequential and diverging scales governs whether the colour ramp reveals or flattens the differences.

Cohort grid anatomy Eight monthly cohorts, one row each, one column per week since joining. The grid is a triangle because older cohorts have more weeks behind them. Reading across a row is one cohort's decay curve; reading down a column compares every cohort at the same age, which is where a product change becomes visible. A triangle, read two ways w0 w1 w2 w3 w4 w5 w6 w7 Jan 100% 62% 48% 41% 37% 35% 34% 34% Feb 100% 62% 48% 41% 37% 35% 34% Mar 100% 62% 48% 41% 37% 35% Apr 100% 62% 48% 41% 37% May 100% 64% 52% 49% Jun 100% 64% 52% Jul 100% 64% Aug 100% 1 2 1 ACROSS A ROW One cohort's decay curve. How fast that group fell away, and whether it flattened into a stable base or kept dropping. 2 DOWN A COLUMN Every cohort's week three, side by side. If the last four are better than the four before them, something you shipped worked. A single averaged retention number cannot show that, because it mixes cohorts at different ages. The shape to look for is a curve that declines and then plateaus: that plateau is a real base of returning users.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

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
--background--foreground--muted-foreground--border--scale-seq-1--scale-seq-2--scale-seq-3--scale-seq-4--scale-seq-5
w0w1w2w3w4w5w6w7
Jan 1,180
Feb 1,240
Mar 1,310
Apr 1,270
May 1,420
Jun 1,390
Jul 1,460
Aug 1,510

Active = opened the app at least once, per week since joining. Cohorts under 200 show no percentages.

CohortGrid.tsxDraws only the cells a cohort has lived through, buckets each against thresholds the caller names, prints the cohort size beside every row and the definition of active under the grid.

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<number | null>(null);
  const step = (v: number) => thresholds.filter((t) => v >= t).length - 1;

  return (
    <div>
      <table className="w-full border-collapse" onMouseLeave={() => setCol(null)}>
        <thead>
          <tr>
            <th />
            {Array.from({ length: columns }, (_, i) => (
              <th key={i} className={cn("pb-1 text-center text-[10px] font-normal text-muted-foreground", col === i && "text-foreground")}>
                {period[0]}{i}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {cohorts.map((c, r) => {
            const thin = c.size < minSize;
            const newest = r === cohorts.length - 1;
            return (
              <tr key={c.label}>
                <th className="pr-2 text-right text-[10px] font-normal text-muted-foreground">
                  {c.label} <span className="tabular-nums opacity-70">{c.size.toLocaleString("en-GB")}</span>
                </th>
                {c.retention.map((v, i) => (
                  <td key={i} className="p-0.5">
                    <button
                      type="button"
                      onMouseEnter={() => setCol(i)}
                      onClick={() => onSelect?.(c, i, v)}
                      className={cn(
                        "flex h-7 w-full items-center justify-center rounded-[2px] text-[10px] tabular-nums",
                        thin ? "border border-dashed text-muted-foreground" : FILL[step(v)],
                        !thin && step(v) >= 3 ? "text-background" : "text-foreground",
                        col === i && "ring-1 ring-foreground/40",
                        // The newest cohort has the least evidence and the most urgency.
                        newest && "outline outline-1 outline-dashed outline-offset-1 outline-muted-foreground",
                      )}
                    >
                      {thin ? "–" : `${v}%`}
                    </button>
                  </td>
                ))}
                {/* 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) => <td key={`e${i}`} className="p-0.5" />)}
              </tr>
            );
          })}
        </tbody>
      </table>
      <p className="mt-3 text-xs text-muted-foreground">
        Active = {retained}, per {period} since joining.
        {minSize > 0 && ` Cohorts under ${minSize} show no percentages.`}
      </p>
    </div>
  );
}

demo.tsxHow it is called: eight monthly cohorts by week, with the May step-up visible down the w1 and w2 columns.

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 (
    <CohortGrid
      cohorts={COHORTS}
      period="week"
      retained="opened the app at least once"
      thresholds={[0, 30, 40, 45, 60]}
      minSize={200}
      onSelect={() => {}}
    />
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

Captures whose hotspots reference this pattern, grouped by product and dated. The dashed boxes are this pattern; hover any box for the note.

Tableau Public

Thousands of dashboards made by people who are not designers, published without a review step. The best available sample of what the pattern language looks like in the wild.

Cohort Retention Analysis September 11, 2026 Tableau Public embed view; workbook published by Tyran Christian medium · light · desktop-web
The triangle, exactly as the entry describes it: thirteen monthly cohorts down the side, thirteen periods since joining across the top, and each row one cell shorter than the last because younger cohorts have had less time. Reading down column two gives the comparison the grid exists for—37%, 22%, 19%, 15%, then a slow climb back to 24%—which no single averaged retention number could show. Two things undercut it. The first column is 100% for every cohort by definition, and it takes the darkest step on the ramp, so the one column carrying no information anchors the scale and compresses every real value between 2% and 50% into what is left. And look along the staircase edge: 9%, 4%, 3%, 2%, 4%, 2%, 4%, 2%, 3%, 4%. Every row's final cell falls off a cliff relative to its neighbour, because that period is still in progress and is being drawn as though it were complete.
  • Cohort grid One row per cohort, one column per period since joining. The triangle is the shape, not a rendering accident.
  • Cohort grid 50% then 9%. The last cell in every row is a partial period drawn as a finished one.
  • Sequential and diverging scales Column one is 100% for everyone and takes the darkest step, so a constant sets the top of the ramp.
  • Ratio and rate Cohort dates, and no cohort size. A 100% first cell could be ten people or ten thousand.