Sextant
← Playground

NRR Waterfall

free

Walks 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-waterfall
State
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>;
}