Survival Curve
A four-state Kaplan–Meier card — stepped survival curves computed from raw follow-up times, censoring ticks on the curve, a log-log confidence band and a numbers-at-risk table pinned to the axis.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-survival.jsonPrompt
Build a React + TypeScript + Tailwind "ChartSurvival" card — a Kaplan–Meier
survival curve — on the shadcn chart primitives (ChartContainer / ChartTooltip
over recharts 3) with zod. Compute the estimate yourself; do not add a stats
library, the formula is six lines.
Why this chart exists (put this in the component's docs too)
- A retention percentage is only honest when every subject has been observed
for the same length of time. Real cohorts are ragged: some joined last week,
some cancelled for unrelated reasons, some are simply still running. Those
rows are CENSORED — event-free the last time you saw them — and they carry
real information: "was still event-free at least this long".
- Deleting censored rows biases the estimate (only failures stay in the
denominator); counting them as events biases it harder. Kaplan-Meier keeps
them in the risk set until the moment they leave, which is the entire point.
- A survival curve drawn WITHOUT censoring marks is wrong even when the maths
is right: a long flat stretch can mean "nothing failed" or "nobody was left
to fail", and only the marks tell those apart. That is why the marks are not
behind a prop in this component.
Contract
- One zod schema is the single source of truth, and it takes RAW OBSERVATIONS,
not a pre-computed curve:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; subjectNoun?: string; eventLabel?: string;
timeUnit?: string;
groups: { id: string; label: string;
observations: { time: finite number >= 0;
event: boolean }[] }[] }
`event: false` means censored. `observations` may be empty — a cohort that
recruited nobody is a state of the world, not a bad feed.
- Refinements that make a bad feed fail loudly: ready needs >= 1 group, group
ids unique, at most 5 groups (each group needs its own colour AND its own
dash pattern, and the palette has five chart tokens).
- Props = z.infer of the schema plus band (false | 0.9 | 0.95 | 0.99, default
0.95), showAtRisk (default true), onRetry?, className. No parallel
hand-written interface.
Behavior - the estimator
- Group observations by distinct time. At each time t with d events and a risk
set of n (everyone whose follow-up reaches t), multiply the running estimate
by (1 - d/n). Subjects censored at t are still at risk at t; that tie
convention is standard, and reversing it shrinks every risk set and drags
the whole curve down.
- Censoring never multiplies anything. It only removes subjects from the risk
set for later times, which is why a censored-only stretch is drawn flat.
- Confidence band: Greenwood's sum, sum over event times of d / (n(n-d)),
carried through the log-log transform - S^exp(+/-z*sigma) with
sigma = sqrt(greenwood)/|ln S|. It cannot propose a survival below 0% or
above 100%, which a naive S +/- z*SE band does at both ends. Skip the term
when n == d (the risk set is wiped out, S is exactly 0 and the interval
collapses); the interval is degenerate at S = 1 and S = 0 on purpose.
- Median = the first time the estimate reaches 50%. If the curve never gets
there, print "not reached" - never the last observed time, never a blank.
- Degenerate inputs are first-class branches, not guards bolted on: zero
observations (never divide by n = 0 - the group draws no line, its at-risk
row is zeros, its legend says "no subjects"), every observation censored
(curve holds at 100%, median not reached), one event that empties the risk
set (S = 0, interval collapses), a single subject censored at time 0. A
"ready" payload whose groups are all empty renders the empty branch rather
than a flat 100% line drawn from nobody.
- Each group's line stops at ITS OWN last observation, not at the axis end:
running it flat to the edge would assert follow-up that never happened.
Rendering & styling
- One ComposedChart: a range Area per group for the band (its dataKey returns
[lower, upper], with tooltipType="none" so it does not double every tooltip
row), a Line per group, then the censoring marks. Both use type="stepAfter":
the estimate is right-continuous, it drops AT the event time and holds until
the next one. XAxis type="number" - time is continuous, and a category axis
would space unequal gaps equally. YAxis pinned to [0, 1] with percent ticks,
never auto-zoomed, because a survival curve is read against 100%.
- Censoring marks are ReferenceDot with a custom `shape` returning a short
vertical <line> in the group's colour, placed at (time, S(time)). Ties
collapse to one tick - the mark says "a censoring happened here", the counts
live in the tooltip and the table.
- Numbers at risk: one row per group under the axis, in the SAME coordinate
system as the plot. Margin bookkeeping makes that exact - the plot box
starts at yAxisWidth + margin.left and ends margin.right from the edge, so a
row inset by those two numbers spans exactly the plot and each column sits
at (t / domainMax) * 100%. Measured: the at-risk row's box equals the grid
box to the pixel and every column lands within 0.01px of its axis tick.
- Responsive by measurement, never by truncation: a ResizeObserver reports the
plot width, the widest at-risk number gives the column width (digits under
tabular-nums share one advance, so a character count is a safe proxy here
where it would not be for prose), and the tick count is
floor(plotWidth / columnWidth) clamped to 2..8. The same ticks drive the
axis AND the table, so they cannot disagree. Ticks are round numbers
(1/2/2.5/5/10 x 10^k) and the domain ends on the last tick.
- Colour is never the only channel: each group gets a chart token AND a dash
pattern (solid / dashed / dotted / dash-dot / fine-dotted), and every legend
swatch is an SVG line carrying that same pattern. Adjacent chart tokens are
only about 1.3:1 against each other.
- Semantic tokens only: bg-card, border, text-muted-foreground, ring,
bg-foreground/20 for the band key, var(--chart-1..5) for the series. Axis
ticks need an explicit tick={{ fill: "var(--muted-foreground)" }} -
recharts 3 no longer nests tick text under .recharts-cartesian-axis-tick, so
the shadcn wrapper's fill hook misses it and labels fall back to #666.
- Accessibility: the chart is role="img" with a summary naming every cohort's
n, events, censored count, final estimate and median, and stating what
censoring means. accessibilityLayer={false} plus tabIndex={-1} - recharts 3
otherwise puts a focusable role="application" on the svg, which inside a
children-presentational role="img" is a keyboard stop with no name. The
visible at-risk block is aria-hidden and the same numbers live in an
sr-only table with real row and column headers, so the cohort is announced
once rather than twice. sr-only goes on a wrapper div, never on the <table>
itself - width:1px is only a minimum for a table box.
- Four first-class branches in one card: loading (header skeleton plus a
descending staircase silhouette), empty (dashed staircase and copy naming
the two things a row needs), error (message plus Try again only when onRetry
exists), ready. Line animation follows a prefers-reduced-motion
subscription; the skeleton pulse carries motion-reduce:animate-none.
Customization levers
- Band: 0.9 / 0.95 / 0.99 pick the z, false drops the band entirely for a
dense grid. The z lookup is three literals - swapping in an inverse-normal
function is the only change needed for arbitrary levels.
- At-risk table: showAtRisk={false} moves it into the sr-only table instead of
deleting it. MIN_TICKS / MAX_TICKS and COLUMN_PADDING_PX tune how eagerly
the axis thins out; raising MAX_TICKS past 8 is exactly what collides
five-digit numbers at 375px, so re-measure if you do.
- Vocabulary: subjectNoun / eventLabel / timeUnit drive every sentence in the
legend, tooltip, summary and table ("accounts", "churn", "days" reads as an
analytics chart; "units", "failure", "cycles" reads as a reliability one).
- Palette and line styles: the SERIES array pairs each chart token with a dash
pattern. Keep the pairing - reusing a pattern is what makes two curves
ambiguous in greyscale.
- Shape: flip to cumulative incidence by plotting 1 - S if your audience
thinks in "% that churned"; keep the domain pinned to [0, 1] either way. Add
a ReferenceLine at 0.5 when the median is the headline number.
- Scale: the estimator is one pass over distinct times, so tens of thousands
of subjects are fine; the DOM cost is one mark per distinct censoring time,
so bucket the times if your feed carries float timestamps.Concepts
- Censoring is information, not missing data — a censored row says "still event-free at least this long". It stays in the denominator until its own time and then leaves without pulling the curve down. Deleting those rows, or scoring them as events, are the two classic ways to publish a wrong retention number — and both look perfectly plausible on the resulting chart.
- Risk set at a time — every drop is
d / n, wherenis everybody whose follow-up reaches that moment, so the same absolute number of events means more late in a study than early. The at-risk row under the axis is that denominator made visible: a 40% estimate computed from 8 remaining subjects is a different fact from one computed from 800. - The mark is not decoration — a flat stretch of curve is ambiguous by construction: nothing failed, or nobody was left to fail. The tick marks are what disambiguate it, which is why this component has no prop to switch them off, and why the legend, the tooltip and the screen-reader table each restate what they mean.
- Right-continuous staircase — the estimate drops at the event time and holds until the next one, so the curve is
stepAfter, not a smoothed line. Interpolating between event times would draw failures at moments where nothing was recorded. - Log-log interval — Greenwood's variance carried through
ln(-ln S)keeps the band inside 0–100%, where a plainS ± z·SEband proposes negative survival on small cohorts. It collapses at both ends on purpose: before the first event the estimate is 1, and once the risk set is emptied it is exactly 0. - Median, or "not reached" — the first time the curve touches 50%. Healthy cohorts often never get there, and the honest answer is "not reached", never the last observed time.
- One tick grid, two consumers — the axis ticks and the at-risk columns are the same computed list placed in the same coordinate system, so they cannot drift apart. A narrow screen reduces the number of ticks (measured plot width divided by the widest at-risk number); it never shrinks or clips the numbers themselves.
Contour
A four-state 2-D density contour plot — Gaussian KDE on a grid, marching-squares rings drawn at highest-density levels, a live smoothing slider, an optional raw scatter underneath, and a hard sample floor under which it refuses to smooth at all.
Tornado Chart
A four-state sensitivity chart — one row per driver, low and high segments measured out from a base-case line that is never assumed to be zero, ranked by swing into a funnel, on an axis that is never forced symmetric.