# CohortTriangle (/docs/charts/cohort-triangle)

CohortTriangle stacks retention cohorts as rows and ages as columns, shading each cell by a discrete retention level.
Because newer vintages have been observed for fewer ages, the block takes the classic **triangle** shape — and reading
down a column compares every cohort at the **same maturity**, which is where "who retains worst" actually lives.

```tsx
<CohortTriangle
  data={[
    { label: "Jan", values: [1, 0.62, 0.48, 0.41, 0.38, 0.37] },
    { label: "Feb", values: [1, 0.58, 0.44, 0.38, 0.35] },
    { label: "Mar", values: [1, 0.47, 0.36, 0.31] },
    { label: "Apr", values: [1, 0.55, 0.42] },
    { label: "May", values: [1, 0.52] },
  ]}
  cell={16}
  unit="month"
  title="Monthly retention"
/>
```

## Install

```tsx
import { CohortTriangle } from "@microcharts/react/cohort-triangle";

<CohortTriangle data={cohorts} unit="month" title="Monthly retention" />
```

Setup (package + stylesheet): [Quickstart](/docs/quickstart#set-up-with-an-ai-agent) or paste [`/agent-setup.md`](/agent-setup.md) into your agent.


## When to use it

- **Good for** — monthly or weekly cohorts side by side, spotting which vintage decays worst, reading retention at equal
  maturity in a KPI card.
- **Avoid for** — exact per-cell values (color intensity is deliberately approximate; pair it with a number when
  precision matters), or a single cohort's decay, where [RetentionCurve](/docs/charts/retention-curve) draws the curve
  directly.

## Sizing

CohortTriangle sizes from `cell` — the edge length of one square in viewBox units. Bump it to scale the whole grid; the
viewBox keeps the aspect ratio when CSS drives the width. Row labels seat automatically and drop out cleanly below
roughly an 8-unit cell.

## Variants

Row labels are on by default. Turn them off for a dense, glanceable block, or `highlight` one vintage to anchor the
equal-maturity comparison.

```tsx
<CohortTriangle data={cohorts} highlight="Mar" />
<CohortTriangle data={cohorts} labels={false} cell={9} />
```

The `unit` prop renames the age columns in the summary and announcements — `"month"`, `"week"`, `"day"`, whatever the
cohort period is.

## Edge cases

```tsx
<CohortTriangle data={[{ label: "Jan", values: [1, 0.6, 0.45, 0.4] }]} unit="month" />
```

```tsx
// a null / non-finite value is a measured-nothing slot: outlined, never shaded as 0%
<CohortTriangle data={[{ label: "Jan", values: [1, 0.6, null, 0.4] }]} />
```

```tsx
// values over 1.001 are auto-detected as percent, not fraction — same shading
<CohortTriangle data={[{ label: "Jan", values: [100, 62, 48] }, { label: "Feb", values: [100, 47] }]} />
```

```tsx
<CohortTriangle
  data={[{ label: "Jan", values: [1, 0.62, 0.48] }, { label: "Feb", values: [1, 0.47] }]}
  unit="month"
  locale="de-DE"
/>
```

A single cohort skips the comparison and just states its first reading. A `null` (or any non-finite) value renders as an
outlined **gap** slot — a "measured nothing here" cell, never a shaded 0%. `data` accepts either a 0–1 fraction or a
0–100 percent series — whichever the max value implies — so a raw percent export renders identically to its fraction
form. With a `locale`, every announced number follows that locale's own formatting.


## Why this default

Rows are cohorts in input order and columns are age, because the decision — _which vintage retains worst_ — is a
**vertical** read at a fixed age, and the ragged right edge falls out naturally from newer cohorts simply having fewer
observed ages. Intensity is quantized to five discrete levels (never a continuous ramp): a smooth gradient would imply a
precision a handful of pixels cannot deliver. A worst-vintage flag is **off** by default — the marks stay honest and the
summary names the laggard in words instead of painting a verdict onto the grid.

## Accessibility

Intensity is a color channel, so CohortTriangle always pairs it with a numeric summary that compares at equal maturity —
**"5 cohorts; at month 1, Mar retains worst (47%); newest May starts at 100%."**. Direction is stated in words, never by
color alone, so it survives forced-colors and color-blind viewing. The interactive entry adds 2-D arrow-key navigation,
announcing each cell as _"Feb cohort, month 1: 58%"_ as you move.

The interactive entry follows the shared [interaction contract](/docs/accessibility#one-interaction-contract):
arrow keys rove between units on both axes, `Home` and `End` jump to the ends, and a click, tap, `Enter` or
`Space` selects a unit — pinning its readout so it survives blur, until you select it again or press `Escape`.
On touch, a tap pins and a drag scrubs.

## Props

| Prop | Type | Description |
| --- | --- | --- |
| `data` (required) | `{ label, values }[]` | One row per cohort; values[i] = retention at age i (0–1 or 0–100, ragged). |
| `labels` | `boolean` | Cohort labels in a left gutter (default true; drops at tiny cell sizes). |
| `highlight` | `string` | Ring the cohort with this label — the comparison focus. |
| `unit` | `string` | Age-column noun for the summary (default "period"). |
| `cell` | `number` | Cell edge length in viewBox units. |
| `title` | `string` | Accessible name; joins the auto summary. |
| `summary` | `string \| false` | Override or disable the auto summary. |
| `animate` | `boolean` | (interactive) Opt-in entrance motion when the chart mounts client-side — add `import "@microcharts/react/motion"` once. Inert on the server, on hydrated server HTML, and under `prefers-reduced-motion`. |

Plus the shared grammar — `data`, `domain`, `color`, `title`, `summary`, `format` — and the layout props (`width`, `height`, `className`, `style`) that every chart accepts. Interactive entries also share `animate` and `live`, and — wherever a chart has more than one navigable unit — `onActive`, `onSelect`, `selectedIndex` and `defaultSelectedIndex`; and — wherever the chart shows a hover value — `readout`. See [the shared grammar](/docs/quickstart#the-shared-grammar).
