Skip to content
KONIGI Patterns

Dashboards / Charts and data displays / Cohort grid

‹ 4 of 22 ›

Cohort grid

People need to compare how groups return over time after joining.

Updated September 10, 2026

Problem

An overall retention rate mixes people who joined at different times. It can hide whether newer groups are returning more often.

Solution

Group people by when they joined. Give each group a row and each elapsed period a column, showing the share still active in each cell.

Reading across follows one group over time. Reading down compares groups at the same age. Leave future periods clearly unavailable rather than showing them as zero.

Define what counts as active and show each group’s starting size. Use a consistent color scale so differences are comparable. Better retention in a newer group is evidence to investigate, not proof that a particular product change caused it.

Use when

Repeat use matters and people need to compare groups at the same stage after joining.

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

New groups have less history, so they cannot answer long-term questions yet. Small groups can produce unstable percentages. The grid needs clear labels because its empty triangle can be mistaken for missing data.

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 shows drop-off through steps. Calendar heatmap aligns values by calendar date. Heatmap provides the general grid form.

Compare periods compares time windows without separating groups. Sequential and diverging scales controls the color encoding.

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

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Tableau Public

Published workbooks show how individual authors arrange charts and interactions in Tableau.

Cohort Retention Analysis September 11, 2026 Tableau Public embed view; workbook published by Tyran Christian medium · light · desktop-web
The grid follows monthly groups across periods since joining. Newer groups have fewer completed periods, creating the triangular shape. The first column is always 100%, taking the darkest color and leaving a narrower visual range for later values. Final cells also show sharp drops; the view needs to clarify whether those periods are complete before readers compare them. Group sizes are not shown.