NRR Waterfall
freeWalks the existing customer base — expansion, contraction, churn — into ending ARR, with net and gross revenue retention and a movement narrative.
Live preview
npx shadcn add @sextant/growth-nrr-waterfallState
Size
Mode
ARR expansion bridge
108%
NRR · 94% gross · Q2 2026
- Expansion
- Contraction / churn
ARR ended Q2 2026 at $4.51M (+$315k net new), with 107.5% net and 93.7% gross retention.
Revenue analytics · reconciled nightly
Source — this is the whole widget
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import { ChartCard } from "@/registry/default/widget-kit/chart-card";
import {
resolveNarrative,
type NarrativeProp,
} from "@/registry/default/chart-narrative/chart-narrative";
import type { Insight } from "@/registry/default/sextant-insights/types";
import { StudioTarget, useStudio } from "@/registry/default/widget-kit/studio";
import { useChartSize } from "@/registry/default/widget-kit/use-chart-size";
import type { SizeProp } from "@/registry/default/widget-kit/chart-size";
import type { ChartClassNames } from "@/registry/default/widget-kit/contract";
import {
WaterfallPlot,
bridgeToRows,
type WaterfallBridgeInput,
type WaterfallRow,
} from "@/registry/default/waterfall-plot/waterfall-plot";
import {
FloatingPanelsProvider,
type Drilldown,
} from "@/registry/default/chart-floating-panels/chart-floating-panels";
import { PanelDragLayer } from "@/registry/default/chart-floating-panels/panel-drag-layer";
import {
makeBarDrilldown,
type BarDatum,
} from "@/registry/default/chart-floating-panels/bar-detail";
import { safeData } from "@/registry/default/sextant-contracts/contracts";
import { ChartStates } from "@/registry/default/chart-states/chart-states";
import { nrrInputSchema, nrrMovement, type NrrInput } from "./data";
// Re-export the data contract so consumers get the schema + types from a
// single import (no reaching into ./data).
export { nrrInputSchema, type NrrInput } from "./data";
type Props = {
// ── Data ────────────────────────────────────────────────────────────────
data?: NrrInput;
// ── Card chrome ───────────────────────────────────────────────────────────
label?: string;
attribution?: string;
className?: string;
classNames?: ChartClassNames;
size?: SizeProp;
// ── Customize ───────────────────────────────────────────────────────────
/** Override currency formatting (hero context, tooltip, axis). */
valueFormatter?: (n: number) => string;
/** Replace, hide (`false`), or fully render the one-line narrative. */
narrative?: NarrativeProp;
// ── Floating drill-down panels ────────────────────────────────────────────
/**
* Enable drag-out floating panels: drag a movement bar (Expansion /
* Contraction / Churn) out of the bridge to spawn a draggable, resizable
* drill-down panel beside the chart (or click a bar / focus it and press
* Enter). Default off, so the widget is byte-identical at rest unless opted
* in. Auto-falls back to stacked panels below the card on phones. Powered by
* `@sextant/chart-floating-panels`.
*/
floatingPanels?: boolean;
/**
* Map a movement step to its panel content. Defaults to a built-in
* value / share / rank breakdown. The Start / End anchors are never drillable.
*/
drilldown?: Drilldown<WaterfallRow>;
};
function fmtUsd(v: number): string {
if (Math.abs(v) >= 1_000_000) {
const m = v / 1_000_000;
return `$${(Math.abs(m) >= 10 ? m.toFixed(1) : m.toFixed(2)).replace(/\.?0+$/, "")}M`;
}
return Math.abs(v) >= 1000 ? `$${(v / 1000).toFixed(0)}k` : `$${v.toLocaleString()}`;
}
const fmtPct = (n: number): string => `${(n * 100).toFixed(0)}%`;
export function GrowthNrrWaterfall(props: Props) {
// Validate consumer data at the boundary: a bad shape shows the error
// state instead of crashing. The built-in demo data is trusted, so skip it.
const parsed = props.data === undefined ? null : safeData(nrrInputSchema, props.data);
if (parsed && !parsed.ok) {
return (
<ChartStates status="error" shape="area" error={{ message: parsed.error }}>
{null}
</ChartStates>
);
}
return <GrowthNrrWaterfallImpl {...props} />;
}
function GrowthNrrWaterfallImpl({
data = nrrMovement,
label,
attribution,
className,
classNames,
size = "auto",
valueFormatter,
narrative,
floatingPanels = false,
drilldown,
}: Props) {
const studio = useStudio();
const { touch } = useChartSize();
const title = studio.text("title", label ?? "ARR expansion bridge", {
label: "Chart title",
});
const fmt = valueFormatter ?? fmtUsd;
const { period, startingArr, expansion, contraction, churn } = data;
const ending = startingArr + expansion - contraction - churn;
const nrr = startingArr > 0 ? ending / startingArr : 0;
const grr = startingArr > 0 ? (startingArr - contraction - churn) / startingArr : 0;
const netNew = expansion - contraction - churn;
// Walk the existing base: Start → +expansion → −contraction → −churn → End.
const bridge = React.useMemo<WaterfallBridgeInput>(() => {
const deltas = [
{ label: "Expansion", delta: expansion },
{ label: "Contraction", delta: -contraction },
{ label: "Churn", delta: -churn },
];
let running = startingArr;
const steps = deltas.map((d) => {
const start = running;
const end = running + d.delta;
running = end;
return { label: d.label, delta: d.delta, start, end };
});
return { from: startingArr, to: ending, steps };
}, [startingArr, expansion, contraction, churn, ending]);
const rows = React.useMemo(() => bridgeToRows(bridge), [bridge]);
// ── Floating drill-down panels ──────────────────────────────────────────
// The prop is the hard gate; within it a Studio toggle governs (no-op in
// production) — the same "prop gates, toggle governs" discipline as the
// reference waterfall.
const panelsOn =
floatingPanels &&
studio.toggle("floatingPanels", true, {
label: "Floating panels",
onLabel: "On",
offLabel: "Off",
});
// No horizontal room on a phone → panels stack below the card.
const panelMode = touch ? "stacked" : "float";
// One drillable bar per MOVEMENT row — skip the Start / End anchors so they
// stay inert (makeBarDrilldown returns null for keys not in `panelBars`).
const panelBars: BarDatum[] = rows
.filter((r) => r.kind !== "anchor")
.map((r) => ({ key: r.step, label: r.step, value: r.signed }));
const resolvedDrilldown =
drilldown ??
makeBarDrilldown<WaterfallRow>({
bars: panelBars,
format: fmt,
accent: "var(--chart-positive)",
unit: "ARR",
});
const ghostValue = (r: WaterfallRow) =>
`${r.signed >= 0 ? "+" : "−"}${fmt(Math.abs(r.signed))}`;
const insight: Insight = {
kind: "arrMovement",
endingArr: ending,
netNewArr: netNew,
nrr,
grr,
period,
};
const card = (
<ChartCard.Root size={size} className={cn(className, classNames?.root)}>
<ChartCard.Header className={classNames?.header}>
<ChartCard.HeaderMain>
<StudioTarget fieldKey="title" variant="inline">
<ChartCard.Label>{title}</ChartCard.Label>
</StudioTarget>
<ChartCard.HeroNumber>{fmtPct(nrr)}</ChartCard.HeroNumber>
<p className="tabular text-sm text-muted-foreground">
NRR · {fmtPct(grr)} gross · {period}
</p>
</ChartCard.HeaderMain>
<ChartCard.Legend
items={[
{ label: "Expansion", color: "var(--chart-positive)" },
{ label: "Contraction / churn", color: "var(--chart-negative)" },
]}
/>
</ChartCard.Header>
{resolveNarrative(narrative, insight, { className: classNames?.narrative })}
<ChartCard.Body className={classNames?.body}>
<WaterfallPlot
rows={rows}
yTickFormatter={(v) => fmt(v).replace("$", "")}
renderTooltipValue={(row) => (
<span className="tabular">
{row.kind === "anchor" ? "" : row.signed >= 0 ? "+" : "-"}
{fmt(Math.abs(row.signed))}
</span>
)}
overlay={
panelsOn ? (
<PanelDragLayer
bars={rows}
getKey={(r) => r.step}
getSpan={(r) => [r.base, r.base + r.amount]}
orientation="vertical"
drilldown={resolvedDrilldown}
ghostValue={ghostValue}
/>
) : undefined
}
/>
</ChartCard.Body>
<ChartCard.Footnote className={classNames?.footnote}>
{attribution ?? "Revenue analytics · reconciled nightly"}
</ChartCard.Footnote>
</ChartCard.Root>
);
// Wrap the card in the floating-panels stage only when enabled — at rest the
// widget is unchanged (the provider adds one relative wrapper and nothing else).
if (!panelsOn) return card;
return <FloatingPanelsProvider mode={panelMode}>{card}</FloatingPanelsProvider>;
}