Fintech & Payments

Cashflow Forecast

A runway projection area chart that walks cash forward from revenue and burn, marks the insolvency line, and annotates where the balance crosses zero.

Install

npx shadcn@latest add @paragon/cashflow-forecast

cashflow-forecast.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface CashflowForecastProps extends React.ComponentProps<"div"> {
  /** Starting cash balance in dollars. */
  startingCash: number;
  /** Monthly recurring revenue at month 0. */
  monthlyRevenue: number;
  /** Monthly burn (fixed costs) in dollars. */
  monthlyBurn: number;
  /** Month-over-month revenue growth multiplier, e.g. 1.08. */
  growthRate?: number;
  /** Months to project. */
  months?: number;
  /** X-axis labels; defaults to M0, M1, … */
  labels?: string[];
  currency?: string;
  static?: boolean;
}

/**
 * A runway projection: cash balance walked forward month by month from
 * revenue (compounding at the growth rate) minus burn, drawn as a hand-built
 * SVG area chart. The area rises from the baseline on first view via a scaleY
 * transform, a zero-line marks insolvency, and the point where the balance
 * crosses zero is annotated as the runway. Feed `growthRate` from a scenario
 * slider to see best/worst cases redraw. Reduced motion draws the final state.
 */
export function CashflowForecast({
  startingCash,
  monthlyRevenue,
  monthlyBurn,
  growthRate = 1.06,
  months = 12,
  labels,
  currency = "USD",
  static: isStatic = false,
  className,
  ...props
}: CashflowForecastProps) {
  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 money = React.useMemo(
    () =>
      new Intl.NumberFormat("en-US", {
        style: "currency",
        currency,
        notation: "compact",
        maximumFractionDigits: 1,
      }),
    [currency],
  );
  const moneyFull = React.useMemo(
    () =>
      new Intl.NumberFormat("en-US", {
        style: "currency",
        currency,
        maximumFractionDigits: 0,
      }),
    [currency],
  );

  // Walk the balance forward.
  const series = React.useMemo(() => {
    const out: number[] = [];
    let cash = startingCash;
    let rev = monthlyRevenue;
    for (let m = 0; m <= months; m++) {
      out.push(cash);
      cash += rev - monthlyBurn;
      rev *= growthRate;
    }
    return out;
  }, [startingCash, monthlyRevenue, monthlyBurn, growthRate, months]);

  const runwayMonth = series.findIndex((v) => v < 0);
  const n = series.length;
  const height = 220;
  const pad = { top: 12, right: 12, bottom: 24, left: 12 };
  const innerW = Math.max(width - pad.left - pad.right, 0);
  const plotBottom = height - pad.bottom;
  const innerH = plotBottom - pad.top;

  const dataMin = Math.min(0, ...series);
  const dataMax = Math.max(0, ...series);
  const range = dataMax - dataMin || 1;

  const xFor = (i: number) =>
    pad.left + (n > 1 ? (i / (n - 1)) * innerW : innerW / 2);
  const yFor = (v: number) => plotBottom - ((v - dataMin) / range) * innerH;
  const zeroY = yFor(0);

  const line = series
    .map((v, i) => `${i === 0 ? "M" : "L"}${xFor(i)} ${yFor(v)}`)
    .join("");
  const area = `${line}L${xFor(n - 1)} ${plotBottom}L${xFor(0)} ${plotBottom}Z`;

  const monthLabel = (i: number) =>
    labels?.[i] ?? (i === 0 ? "Now" : `M${i}`);

  function handleMove(e: React.PointerEvent<SVGSVGElement>) {
    const rect = ref.current?.getBoundingClientRect();
    if (!rect || innerW <= 0) return;
    const px = e.clientX - rect.left;
    const frac = (px - pad.left) / innerW;
    setHover(Math.min(n - 1, Math.max(0, Math.round(frac * (n - 1)))));
  }

  const ok = runwayMonth === -1;

  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">Cash runway</p>
          <p className="text-[13px] text-muted-foreground">
            {ok ? (
              <span className="text-success">Cash-flow positive over {months}mo</span>
            ) : (
              <>
                Runway ends{" "}
                <span className="font-medium text-warning">
                  {monthLabel(runwayMonth)}
                </span>
              </>
            )}
          </p>
        </div>
        <div className="text-right">
          <p className="text-lg font-semibold tabular-nums">
            {moneyFull.format(series[series.length - 1])}
          </p>
          <p className="text-[12px] text-muted-foreground">
            projected · {monthLabel(n - 1)}
          </p>
        </div>
      </div>

      <div
        ref={ref}
        className="relative mt-3 w-full"
        style={{ height }}
      >
        {width > 0 && (
          <svg
            role="img"
            aria-label={`Projected cash balance over ${months} months`}
            width={width}
            height={height}
            viewBox={`0 0 ${width} ${height}`}
            className="block text-foreground"
            onPointerMove={handleMove}
            onPointerLeave={() => setHover(null)}
          >
            <defs>
              <linearGradient id="cf-fill" x1="0" y1="0" x2="0" y2="1">
                <stop offset="0%" stopColor="var(--color-success)" stopOpacity="0.28" />
                <stop offset="100%" stopColor="var(--color-success)" stopOpacity="0.02" />
              </linearGradient>
            </defs>

            {/* Zero / insolvency line */}
            <line
              x1={pad.left}
              x2={width - pad.right}
              y1={zeroY}
              y2={zeroY}
              stroke="var(--color-destructive)"
              strokeOpacity={0.5}
              strokeDasharray="4 4"
            />

            <g
              style={{
                transform: drawn ? "scaleY(1)" : "scaleY(0)",
                transformOrigin: `0px ${zeroY}px`,
                transition: animate
                  ? "transform 650ms var(--ease-out)"
                  : undefined,
              }}
            >
              <path d={area} fill="url(#cf-fill)" />
              <path
                d={line}
                fill="none"
                stroke="var(--color-success)"
                strokeWidth={2}
                strokeLinejoin="round"
                vectorEffect="non-scaling-stroke"
              />
            </g>

            {hover !== null && (
              <>
                <line
                  x1={xFor(hover)}
                  x2={xFor(hover)}
                  y1={pad.top}
                  y2={plotBottom}
                  stroke="currentColor"
                  strokeOpacity={0.2}
                  aria-hidden
                />
                <circle
                  cx={xFor(hover)}
                  cy={yFor(series[hover])}
                  r={3.5}
                  fill="var(--color-background)"
                  stroke="var(--color-success)"
                  strokeWidth={2}
                  aria-hidden
                />
              </>
            )}

            {[0, Math.floor((n - 1) / 2), n - 1].map((i) => (
              <text
                key={i}
                x={xFor(i)}
                y={height - 6}
                textAnchor={i === 0 ? "start" : i === n - 1 ? "end" : "middle"}
                fontSize={10}
                className="fill-muted-foreground"
              >
                {monthLabel(i)}
              </text>
            ))}
          </svg>
        )}

        <div
          aria-hidden
          className="pointer-events-none absolute top-0 left-0 z-10 rounded-lg bg-popover px-2.5 py-1.5 text-xs shadow-overlay transition-opacity duration-100"
          style={{
            opacity: hover !== null ? 1 : 0,
            transform:
              hover !== null
                ? `translate(${Math.min(xFor(hover) + 12, width - 130)}px, 8px)`
                : undefined,
          }}
        >
          <div className="text-muted-foreground">
            {hover !== null ? monthLabel(hover) : ""}
          </div>
          <div className="font-medium tabular-nums">
            {hover !== null ? moneyFull.format(series[hover]) : ""}
          </div>
        </div>
      </div>

      <div className="mt-2 flex items-center justify-between text-[12px] text-muted-foreground tabular-nums">
        <span>Start {money.format(startingCash)}</span>
        <span>
          MRR {money.format(monthlyRevenue)} · burn {money.format(monthlyBurn)}
        </span>
      </div>
    </div>
  );
}