"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type FunnelStage = { id?: string; label: string; value: number; /** Extra line shown in the tooltip. */ hint?: string };
export type FunnelChartProps = {
stages: FunnelStage[];
title?: string;
description?: string;
valueFormatter?: (v: number) => string;
/** Base colour; defaults to the theme primary. */
color?: string;
/** Plot height for the wide (column) layout. */
height?: number;
/** Below this container width the funnel switches to stacked rows. */
breakpoint?: number;
className?: string;
};
function useElementWidth<T extends HTMLElement>() {
const ref = React.useRef<T>(null);
const [width, setWidth] = React.useState(0);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => setWidth(Math.floor(entries[0].contentRect.width)));
ro.observe(el);
return () => ro.disconnect();
}, []);
return [ref, width] as const;
}
const num = new Intl.NumberFormat("en-US");
const pct = (f: number) => `${(f * 100).toFixed(f >= 0.1 || f === 0 ? 1 : 2)}%`;
const ease = [0.22, 1, 0.36, 1] as const;
export function FunnelChart({
stages,
title,
description,
valueFormatter = (v) => num.format(v),
color = "var(--primary)",
height = 260,
breakpoint = 560,
className,
}: FunnelChartProps) {
const reduce = !!useReducedMotion();
const [ref, width] = useElementWidth<HTMLDivElement>();
const [active, setActive] = React.useState<number | null>(null);
const n = stages.length;
const first = Math.max(1, stages[0]?.value ?? 1);
const wide = width >= breakpoint;
const shade = (i: number) => `color-mix(in oklch, ${color} ${100 - (i / Math.max(1, n - 1)) * 38}%, var(--card))`;
const connector = `color-mix(in oklch, ${color} 14%, transparent)`;
const stats = stages.map((s, i) => {
const prev = i === 0 ? s.value : stages[i - 1].value;
return {
...s,
key: s.id ?? `${s.label}-${i}`,
ofTotal: s.value / first,
step: prev ? s.value / prev : 0,
lost: Math.max(0, prev - s.value),
};
});
const overall = n > 1 ? stats[n - 1].value / first : 1;
const focusProps = (i: number) => ({
tabIndex: 0,
role: "listitem",
"aria-label": `${stats[i].label}: ${valueFormatter(stats[i].value)}, ${pct(stats[i].ofTotal)} of total${i > 0 ? `, ${pct(1 - stats[i].step)} drop-off from ${stats[i - 1].label}` : ""}`,
onFocus: () => setActive(i),
onBlur: () => setActive(null),
onPointerEnter: () => setActive(i),
onPointerLeave: () => setActive(null),
onKeyDown: (e: React.KeyboardEvent<SVGGElement>) => {
const dir = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
if (!dir) return;
e.preventDefault();
const sib = (e.currentTarget.parentNode as SVGGElement | null)?.querySelectorAll<SVGGElement>("[data-stage]")[i + dir];
sib?.focus();
},
"data-stage": i,
className: "cursor-pointer outline-none",
});
/* ------------------------------ wide layout ------------------------------ */
const top = 34;
const bottom = 40;
const colW = width / Math.max(n, 1);
const barW = Math.min(colW * 0.58, 120);
const plotH = height - top - bottom;
const barX = (i: number) => i * colW + (colW - barW) / 2;
const barH = (i: number) => Math.max(2, (stats[i].value / first) * plotH);
const base = top + plotH;
/* ------------------------------ narrow layout ---------------------------- */
const barHN = 22;
const rowH = 20 + barHN;
const linkH = 14;
const narrowH = n * rowH + Math.max(0, n - 1) * linkH;
const nw = (i: number) => Math.max(8, (stats[i].value / first) * width);
const ny = (i: number) => i * (rowH + linkH);
let tip: { style: React.CSSProperties; translate: string } | null = null;
if (active !== null) {
if (wide) {
const top = Math.max(0, Math.min(base - barH(active), base - 120));
const rightSpace = width - (barX(active) + barW);
tip = {
style: rightSpace > 210 ? { left: barX(active) + barW + 10, top } : { right: width - barX(active) + 10, top },
translate: "",
};
} else {
tip = { style: { left: Math.min(Math.max(nw(active) / 2, 100), width - 100), top: ny(active) + rowH + 6 }, translate: "-translate-x-1/2" };
}
}
return (
<figure className={cn("w-full rounded-xl border bg-card p-4 text-card-foreground shadow-xs sm:p-5", className)}>
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
{title && <figcaption className="text-sm font-medium text-muted-foreground">{title}</figcaption>}
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground">Overall conversion</p>
<p className="text-2xl font-semibold tracking-tight tabular-nums">{pct(overall)}</p>
</div>
</div>
<div ref={ref} className="relative w-full" style={{ height: width ? (wide ? height : narrowH) : height }}>
{width > 0 && wide && (
<svg width={width} height={height} className="block overflow-visible" role="list" aria-label={title ?? "Funnel"}>
<line x1={0} x2={width} y1={base} y2={base} className="stroke-border" />
{stats.map((s, i) => {
if (i === n - 1) return null;
const x0 = barX(i) + barW;
const x1 = barX(i + 1);
const y0 = base - barH(i);
const y1 = base - barH(i + 1);
const mid = (x0 + x1) / 2;
return (
<motion.g
key={`c-${s.key}`}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: active === null || active === i + 1 || active === i ? 1 : 0.35 }}
transition={{ duration: 0.4, delay: reduce ? 0 : 0.5 + i * 0.08 }}
aria-hidden
>
<path d={`M${x0},${y0}C${mid},${y0},${mid},${y1},${x1},${y1}L${x1},${base}L${x0},${base}Z`} fill={connector} />
<g transform={`translate(${mid},${Math.max(y1 - 18, top - 6)})`}>
<rect x={-24} y={-10} width={48} height={20} rx={10} className="fill-card stroke-border" />
<text textAnchor="middle" dy="0.34em" className="fill-muted-foreground text-[10.5px] font-medium tabular-nums">
−{pct(1 - stats[i + 1].step).replace(".0%", "%")}
</text>
</g>
</motion.g>
);
})}
{stats.map((s, i) => (
<g key={s.key} {...focusProps(i)}>
<rect x={i * colW} y={0} width={colW} height={height} fill="transparent" />
<motion.rect
x={barX(i)}
width={barW}
rx={6}
fill={shade(i)}
initial={reduce ? false : { y: base, height: 0 }}
animate={{ y: base - barH(i), height: barH(i), opacity: active === null || active === i ? 1 : 0.55 }}
transition={{ y: { duration: 0.8, ease, delay: i * 0.08 }, height: { duration: 0.8, ease, delay: i * 0.08 }, opacity: { duration: 0.15 } }}
/>
{active === i && <rect x={barX(i) - 3} y={base - barH(i) - 3} width={barW + 6} height={barH(i) + 3} rx={8} fill="none" className="stroke-ring" strokeWidth={1.5} />}
<text x={barX(i) + barW / 2} y={base - barH(i) - 8} textAnchor="middle" className="fill-foreground text-xs font-semibold tabular-nums">
{valueFormatter(s.value)}
</text>
<text x={barX(i) + barW / 2} y={base + 18} textAnchor="middle" className="fill-foreground text-xs font-medium">
{s.label}
</text>
<text x={barX(i) + barW / 2} y={base + 33} textAnchor="middle" className="fill-muted-foreground text-[11px] tabular-nums">
{pct(s.ofTotal)}
</text>
</g>
))}
</svg>
)}
{width > 0 && !wide && (
<svg width={width} height={narrowH} className="block" role="list" aria-label={title ?? "Funnel"}>
{stats.map((s, i) => {
const y0 = ny(i);
const w = nw(i);
const prevW = i === 0 ? w : nw(i - 1);
return (
<g key={s.key} {...focusProps(i)}>
<rect x={0} y={y0} width={width} height={rowH} fill="transparent" />
<text x={0} y={y0 + 12} className="fill-foreground text-xs font-medium">
{s.label}
</text>
<text x={width} y={y0 + 12} textAnchor="end" className="fill-foreground text-xs font-semibold tabular-nums">
{i > 0 && <tspan className="fill-muted-foreground font-normal">−{pct(1 - s.step)} </tspan>}
{valueFormatter(s.value)}
</text>
{i > 0 && (
<motion.rect
aria-hidden
x={0}
y={y0 + 20}
height={barHN}
rx={6}
fill={connector}
initial={reduce ? false : { width: 0 }}
animate={{ width: prevW }}
transition={{ duration: 0.8, ease, delay: (i - 1) * 0.08 }}
/>
)}
<motion.rect
x={0}
y={y0 + 20}
height={barHN}
rx={6}
fill={shade(i)}
initial={reduce ? false : { width: 0 }}
animate={{ width: w, opacity: active === null || active === i ? 1 : 0.55 }}
transition={{ width: { duration: 0.8, ease, delay: 0.15 + i * 0.08 }, opacity: { duration: 0.15 } }}
/>
{active === i && <rect x={-3} y={y0 + 17} width={Math.max(w, prevW) + 6} height={barHN + 6} rx={9} fill="none" className="stroke-ring" strokeWidth={1.5} />}
</g>
);
})}
</svg>
)}
{tip && active !== null && (
<div
className={cn("pointer-events-none absolute z-10 min-w-48 rounded-lg border bg-popover/95 px-3 py-2 text-xs text-popover-foreground shadow-lg backdrop-blur-sm", tip.translate)}
style={tip.style}
>
<p className="font-medium">{stats[active].label}</p>
{stats[active].hint && <p className="text-muted-foreground">{stats[active].hint}</p>}
<dl className="mt-1.5 grid grid-cols-[1fr_auto] gap-x-4 gap-y-0.5 tabular-nums">
<dt className="text-muted-foreground">Users</dt>
<dd className="text-right font-medium">{valueFormatter(stats[active].value)}</dd>
<dt className="text-muted-foreground">Of total</dt>
<dd className="text-right font-medium">{pct(stats[active].ofTotal)}</dd>
{active > 0 && (
<>
<dt className="text-muted-foreground">Step conversion</dt>
<dd className="text-right font-medium">{pct(stats[active].step)}</dd>
<dt className="text-muted-foreground">Dropped</dt>
<dd className="text-right font-medium text-rose-600 dark:text-rose-400">−{valueFormatter(stats[active].lost)}</dd>
</>
)}
</dl>
</div>
)}
</div>
</figure>
);
}