An MRR movement chart with new and expansion MRR stacked above the axis and churn dropping below, bars rising on view and the ending-MRR headline counting up.
npx shadcn@latest add @paragon/mrr-chartAlso installs: number-ticker
"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
export interface MrrMonth {
label: string;
/** New MRR added (positive). */
newMrr: number;
/** Expansion MRR from existing customers (positive). */
expansion: number;
/** Churned MRR (pass a positive number; drawn below the axis). */
churn: number;
}
export interface MrrChartProps extends React.ComponentProps<"div"> {
data: MrrMonth[];
/** MRR at the start of the first period, to compute the running total. */
startingMrr?: number;
currency?: string;
static?: boolean;
}
const COLORS = {
new: "oklch(0.585 0.17 260)",
expansion: "oklch(0.68 0.13 165)",
churn: "oklch(0.62 0.19 25)",
};
/**
* An MRR movement chart: new and expansion MRR stack above the axis, churn
* drops below it, per month. Bars scale up from the baseline on first view
* with a small per-column stagger (transform-only, so no reflow), and the
* headline ending-MRR figure counts up beside a net-new delta. Hovering a
* column reveals the month's breakdown. Reduced motion renders the final
* state at once. Hand-built SVG — no chart library.
*/
export function MrrChart({
data,
startingMrr = 0,
currency = "USD",
static: isStatic = false,
className,
...props
}: MrrChartProps) {
const ref = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
const reduced = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -48px 0px" });
const [hover, setHover] = React.useState<number | null>(null);
const animate = !isStatic && !reduced;
const drawn = !animate || inView;
React.useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
obs.observe(el);
return () => obs.disconnect();
}, []);
const compact = React.useMemo(
() =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency,
notation: "compact",
maximumFractionDigits: 1,
}),
[currency],
);
// Running MRR and the maxima that scale the axis.
const netByMonth = data.map((m) => m.newMrr + m.expansion - m.churn);
const endingMrr = startingMrr + netByMonth.reduce((s, v) => s + v, 0);
const netNew = netByMonth.reduce((s, v) => s + v, 0);
const maxUp = Math.max(1, ...data.map((m) => m.newMrr + m.expansion));
const maxDown = Math.max(1, ...data.map((m) => m.churn));
const n = data.length;
const height = 220;
const pad = { top: 12, right: 8, bottom: 26, left: 8 };
const innerW = Math.max(width - pad.left - pad.right, 0);
// Split plot around a zero axis, weighted by the up/down magnitudes.
const plotH = height - pad.top - pad.bottom;
const upH = (plotH * maxUp) / (maxUp + maxDown);
const downH = plotH - upH;
const zeroY = pad.top + upH;
const band = n > 0 ? innerW / n : 0;
const barW = band * 0.5;
const xBand = (i: number) => pad.left + i * band + (band - barW) / 2;
return (
<div className={cn("w-full max-w-lg", className)} {...props}>
<div className="flex items-baseline justify-between">
<div>
<p className="text-sm font-medium">Monthly recurring revenue</p>
<p className="text-[13px] text-muted-foreground tabular-nums">
Net new{" "}
<span className={netNew >= 0 ? "text-success" : "text-destructive"}>
{netNew >= 0 ? "+" : "−"}
{compact.format(Math.abs(netNew))}
</span>{" "}
over {n} mo
</p>
</div>
<p className="text-xl font-semibold tabular-nums">
<NumberTicker
value={endingMrr}
static={!animate}
duration={0.7}
formatOptions={{
style: "currency",
currency,
notation: "compact",
maximumFractionDigits: 1,
}}
/>
</p>
</div>
<div ref={ref} className="relative mt-3 w-full" style={{ height }}>
{width > 0 && (
<svg
role="img"
aria-label="Monthly recurring revenue movement by new, expansion, and churn"
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="block text-foreground"
onPointerMove={(e) => {
const rect = ref.current?.getBoundingClientRect();
if (!rect || band <= 0) return;
const px = e.clientX - rect.left;
setHover(
Math.min(n - 1, Math.max(0, Math.floor((px - pad.left) / band))),
);
}}
onPointerLeave={() => setHover(null)}
>
{/* Zero axis */}
<line
x1={pad.left}
x2={width - pad.right}
y1={zeroY}
y2={zeroY}
stroke="currentColor"
strokeOpacity={0.15}
/>
{data.map((m, i) => {
const newH = (m.newMrr / maxUp) * upH;
const expH = (m.expansion / maxUp) * upH;
const churnH = (m.churn / maxDown) * downH;
const hovered = hover === i;
const x = xBand(i);
return (
<g
key={m.label}
style={{
transform: drawn ? "scaleY(1)" : "scaleY(0)",
transformOrigin: `0px ${zeroY}px`,
transition: animate
? `transform 450ms var(--ease-out) ${i * 40}ms`
: undefined,
}}
>
{/* Expansion (top of the up-stack) */}
<rect
x={x}
y={zeroY - newH - expH}
width={barW}
height={Math.max(expH, 0)}
rx={Math.min(3, barW / 2)}
fill={COLORS.expansion}
fillOpacity={hovered ? 1 : 0.85}
/>
{/* New MRR (sits on the axis) */}
<rect
x={x}
y={zeroY - newH}
width={barW}
height={Math.max(newH, 0)}
fill={COLORS.new}
fillOpacity={hovered ? 1 : 0.85}
/>
{/* Churn below the axis */}
<rect
x={x}
y={zeroY}
width={barW}
height={Math.max(churnH, 0)}
rx={Math.min(3, barW / 2)}
fill={COLORS.churn}
fillOpacity={hovered ? 1 : 0.8}
/>
</g>
);
})}
{data.map((m, i) => (
<text
key={m.label}
x={xBand(i) + barW / 2}
y={height - 8}
textAnchor="middle"
fontSize={10}
className={cn(
"fill-muted-foreground",
hover === i && "fill-foreground",
)}
>
{m.label}
</text>
))}
</svg>
)}
<div
aria-hidden
className="pointer-events-none absolute top-0 left-0 z-10 min-w-36 rounded-lg bg-popover px-2.5 py-2 text-xs shadow-overlay transition-opacity duration-100"
style={{
opacity: hover !== null ? 1 : 0,
transform:
hover !== null
? `translate(${Math.min(xBand(hover) + barW + 8, width - 150)}px, 8px)`
: undefined,
}}
>
{hover !== null && (
<>
<div className="text-muted-foreground">{data[hover].label}</div>
<div className="mt-1.5 flex flex-col gap-1">
{(
[
["New", data[hover].newMrr, COLORS.new],
["Expansion", data[hover].expansion, COLORS.expansion],
["Churn", -data[hover].churn, COLORS.churn],
] as const
).map(([label, val, color]) => (
<div key={label} className="flex items-center gap-1.5">
<span
className="size-2 shrink-0 rounded-full"
style={{ background: color }}
/>
<span className="text-muted-foreground">{label}</span>
<span className="ml-auto pl-3 font-medium tabular-nums">
{val >= 0 ? "+" : "−"}
{compact.format(Math.abs(val))}
</span>
</div>
))}
</div>
</>
)}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1">
{(
[
["New", COLORS.new],
["Expansion", COLORS.expansion],
["Churn", COLORS.churn],
] as const
).map(([label, color]) => (
<div
key={label}
className="flex items-center gap-1.5 text-xs text-muted-foreground"
>
<span
className="size-2 rounded-full"
style={{ background: color }}
/>
{label}
</div>
))}
</div>
</div>
);
}