A runway projection area chart that walks cash forward from revenue and burn, marks the insolvency line, and annotates where the balance crosses zero.
npx shadcn@latest add @paragon/cashflow-forecast"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>
);
}