Fintech & Payments

MRR Chart

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.

Install

npx shadcn@latest add @paragon/mrr-chart

Also installs: number-ticker

mrr-chart.tsx

"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>
  );
}