"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
export interface FunnelStage {
/** Stable identity; transitions follow the id. */
id: string;
label: string;
value: number;
}
export interface FunnelChartProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
data: FunnelStage[];
/** Accessible name; also the caption of the screen-reader table. */
label: string;
/** Stages top to bottom, or left to right. */
orientation?: "vertical" | "horizontal";
/** "stepped": bars joined by drop-off bands. "smooth": each stage tapers into the next. */
shape?: "stepped" | "smooth";
/** Conversion percentage between consecutive stages. */
showConversion?: boolean;
/** Drop-off annotation (lost count) between stages. */
showDropoff?: boolean;
/** Header with overall conversion, or the active stage's numbers. */
showSummary?: boolean;
/** Thickness of each stage in px. */
stageSize?: number;
/** Width / number transition in ms. */
duration?: number;
accent?: string;
/** Formats counts (axis-free, so this is the only number formatting). */
format?: (value: number) => string;
}
/* --------------------------- reduced-motion hook --------------------------- */
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* --------------------------------- helpers --------------------------------- */
const defaultNumber = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
const pctText = (n: number) => `${n >= 10 || n === 0 ? n.toFixed(1) : n.toFixed(2)}%`;
const r2 = (n: number) => Math.round(n * 100) / 100;
/** A number that tweens to its new value (instant with reduced motion). */
function TweenNumber({ value, format, duration, reduce }: { value: number; format: (v: number) => string; duration: number; reduce: boolean }) {
const mv = useMotionValue(value);
const text = useTransform(mv, format);
React.useEffect(() => {
if (reduce || duration <= 0) {
mv.jump(value);
return;
}
const controls = animate(mv, value, { duration: duration / 1000, ease: [0.22, 1, 0.36, 1] });
return () => controls.stop();
}, [mv, value, duration, reduce]);
return <motion.span>{text}</motion.span>;
}
/** clip-path for a centred trapezoid: `from` and `to` are widths in % along the flow axis. */
function trapezoid(from: number, to: number, vertical: boolean) {
const a = r2((100 - from) / 2);
const b = r2((100 - to) / 2);
return vertical
? `polygon(${a}% 0%, ${r2(100 - a)}% 0%, ${r2(100 - b)}% 100%, ${b}% 100%)`
: `polygon(0% ${a}%, 100% ${b}%, 100% ${r2(100 - b)}%, 0% ${r2(100 - a)}%)`;
}
/* -------------------------------- component -------------------------------- */
/**
* A conversion funnel. Stage widths are proportional to their counts and transition smoothly
* (CSS clip-path) when the data changes; bands between stages carry the conversion rate and the
* drop-off. Hover or focus a stage for its absolute and relative numbers. One tab stop with arrow
* keys between stages, plus a screen-reader table of every number.
*/
export function FunnelChart({
data,
label,
orientation = "vertical",
shape = "stepped",
showConversion = true,
showDropoff = true,
showSummary = true,
stageSize = 44,
duration = 700,
accent = "#FF7448",
format,
className = "",
style,
...props
}: FunnelChartProps) {
const reduce = usePrefersReducedMotion();
const listRef = React.useRef<HTMLDivElement>(null);
const [active, setActive] = React.useState<string | null>(null);
const [focusId, setFocusId] = React.useState<string | null>(null);
const hintId = React.useId();
const fmt = format ?? ((v: number) => defaultNumber.format(v));
const vertical = orientation === "vertical";
const ms = reduce ? 0 : duration;
const stages = data.map((d) => ({ ...d, value: Math.max(0, d.value) }));
const first = stages[0]?.value ?? 0;
const last = stages[stages.length - 1]?.value ?? 0;
const max = Math.max(1, ...stages.map((s) => s.value));
// Widths in %, with a floor so tiny stages stay visible and hoverable.
const widths = stages.map((s) => Math.max(4, (s.value / max) * 100));
const overall = pct(last, first);
const activeIndex = stages.findIndex((s) => s.id === active);
const tabId = stages.some((s) => s.id === focusId) ? focusId : (stages[0]?.id ?? null);
const focusStage = (i: number) => {
listRef.current?.querySelectorAll<HTMLElement>("[data-fc-stage]")[i]?.focus();
};
const onKeyDown = (event: React.KeyboardEvent<HTMLElement>, i: number) => {
const next = { ArrowDown: i + 1, ArrowRight: i + 1, ArrowUp: i - 1, ArrowLeft: i - 1, Home: 0, End: stages.length - 1 }[event.key];
if (event.key === "Escape") setActive(null);
if (next === undefined || next < 0 || next >= stages.length) return;
event.preventDefault();
focusStage(next);
};
const describe = (i: number) => {
const s = stages[i];
const parts = [`${s.label}: ${fmt(s.value)}`, `${pctText(pct(s.value, first))} of ${stages[0].label}`];
if (i > 0) parts.push(`${pctText(pct(s.value, stages[i - 1].value))} from ${stages[i - 1].label}`);
return parts.join(", ");
};
const transition = { transitionDuration: `${ms}ms` };
const fill = (i: number) => `color-mix(in oklab, var(--fc-accent) ${Math.round(100 - (i / Math.max(1, stages.length - 1)) * 45)}%, transparent)`;
/* Grid tracks along the flow: stages interleaved with connector bands. */
const tracks = stages
.map((_, i) => {
const stage = vertical ? `${stageSize}px` : "minmax(0,1fr)";
const band = vertical ? "minmax(30px,auto)" : "minmax(56px,0.6fr)";
return i < stages.length - 1 ? `${stage} ${band}` : stage;
})
.join(" ");
return (
<div
role="group"
aria-label={label}
className={`w-full min-w-0 text-zinc-950 dark:text-zinc-50 ${className}`}
style={{ ["--fc-accent" as string]: accent, ...style }}
{...props}
>
{showSummary && (
<div aria-hidden="true" className="mb-5 flex min-h-[52px] flex-wrap items-end justify-between gap-x-6 gap-y-1">
<div className="flex min-w-0 flex-col">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{activeIndex >= 0 ? stages[activeIndex].label : "Overall conversion"}
</span>
<span className="text-[28px] leading-none font-semibold tracking-[-0.04em] tabular-nums">
{activeIndex >= 0 ? (
<TweenNumber value={stages[activeIndex].value} format={fmt} duration={240} reduce={reduce} />
) : (
<TweenNumber value={overall} format={pctText} duration={ms} reduce={reduce} />
)}
</span>
</div>
<span className="font-mono text-[11px] tabular-nums text-zinc-500 dark:text-zinc-400">
{activeIndex >= 0
? `${pctText(pct(stages[activeIndex].value, first))} of total${
activeIndex > 0 ? ` · ${pctText(pct(stages[activeIndex].value, stages[activeIndex - 1].value))} of previous` : ""
}`
: `${fmt(last)} of ${fmt(first)}`}
</span>
</div>
)}
<div
ref={listRef}
role="list"
aria-label={`${label} stages`}
aria-describedby={hintId}
onPointerLeave={(e) => e.pointerType === "mouse" && setActive(null)}
className="grid gap-x-3"
style={
vertical
? { gridTemplateColumns: "minmax(0,min(8.5rem,28%)) minmax(0,1fr) minmax(0,min(7rem,22%))", gridTemplateRows: tracks }
: { gridTemplateColumns: tracks, gridTemplateRows: `auto ${stageSize * 4}px auto`, columnGap: 0 }
}
>
{stages.map((s, i) => {
const isActive = active === s.id;
const dim = active !== null && !isActive;
const next = stages[i + 1];
const nextW = next ? widths[i + 1] : widths[i];
const stageClip = trapezoid(widths[i], shape === "smooth" ? nextW : widths[i], vertical);
const row = i * 2 + 1;
const conversion = next ? pct(next.value, s.value) : 0;
const lost = next ? s.value - next.value : 0;
const stageEl = (
<div
key={`${s.id}-stage`}
data-fc-stage=""
role="listitem"
tabIndex={tabId === s.id ? 0 : -1}
aria-label={describe(i)}
onPointerEnter={(e) => e.pointerType === "mouse" && setActive(s.id)}
onClick={() => setActive((a) => (a === s.id ? null : s.id))}
onFocus={() => {
setFocusId(s.id);
setActive(s.id);
}}
onBlur={() => setActive(null)}
onKeyDown={(e) => onKeyDown(e, i)}
className="group/stage relative cursor-pointer rounded-[3px] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--fc-accent)]"
style={vertical ? { gridColumn: 2, gridRow: row } : { gridRow: 2, gridColumn: row }}
>
<div
aria-hidden="true"
className={`absolute inset-0 transition-[clip-path,opacity] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none ${dim ? "opacity-40" : "opacity-100"}`}
style={{ clipPath: stageClip, background: fill(i), ...transition }}
/>
<span
aria-hidden="true"
className={`pointer-events-none absolute inset-0 grid place-items-center font-mono text-[11px] font-medium tabular-nums text-zinc-950 transition-opacity duration-200 ${
widths[i] < 22 ? "opacity-0" : "opacity-100"
}`}
>
{pctText(pct(s.value, first))}
</span>
</div>
);
const labelEl = (
<div
key={`${s.id}-label`}
aria-hidden="true"
className={`flex min-w-0 flex-col justify-center transition-opacity duration-200 ${dim ? "opacity-50" : ""} ${vertical ? "" : "items-center px-1 pb-2 text-center"}`}
style={vertical ? { gridColumn: 1, gridRow: row } : { gridRow: 1, gridColumn: row }}
>
<span className="truncate text-[13px] font-medium tracking-[-0.01em]">{s.label}</span>
{!vertical && (
<span className="font-mono text-[11px] tabular-nums text-zinc-500 dark:text-zinc-400">
<TweenNumber value={s.value} format={fmt} duration={ms} reduce={reduce} />
</span>
)}
</div>
);
const valueEl = vertical ? (
<div
key={`${s.id}-value`}
aria-hidden="true"
className={`flex min-w-0 flex-col items-end justify-center text-right transition-opacity duration-200 ${dim ? "opacity-50" : ""}`}
style={{ gridColumn: 3, gridRow: row }}
>
<span className="text-[15px] font-semibold tracking-[-0.02em] tabular-nums">
<TweenNumber value={s.value} format={fmt} duration={ms} reduce={reduce} />
</span>
</div>
) : null;
const connector = next ? (
<React.Fragment key={`${s.id}-band`}>
<div
aria-hidden="true"
className="relative"
style={vertical ? { gridColumn: 2, gridRow: row + 1 } : { gridRow: 2, gridColumn: row + 1 }}
>
{shape === "stepped" && (
<div
className="absolute inset-0 bg-[var(--fc-accent)] opacity-[0.13] transition-[clip-path] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none dark:opacity-[0.16]"
style={{ clipPath: trapezoid(widths[i], nextW, vertical), ...transition }}
/>
)}
{showConversion && (
<span className="absolute inset-0 grid place-items-center">
<span className="rounded-full border border-zinc-900/10 bg-white px-1.5 py-px font-mono text-[10px] whitespace-nowrap tabular-nums text-zinc-700 shadow-[0_1px_2px_rgb(0_0_0/0.06)] dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-200">
{vertical ? "↓ " : ""}{pctText(conversion)}
</span>
</span>
)}
</div>
{showDropoff && (
<div
aria-hidden="true"
className={`flex min-w-0 items-center font-mono text-[10px] tabular-nums text-zinc-500 dark:text-zinc-400 ${
vertical ? "justify-end" : "justify-center pt-2 text-center"
}`}
style={vertical ? { gridColumn: 3, gridRow: row + 1 } : { gridRow: 3, gridColumn: row + 1 }}
>
<span className={`flex min-w-0 gap-x-1 ${vertical ? "flex-row flex-wrap justify-end" : "flex-col"}`}>
<span className="whitespace-nowrap">−{fmt(lost)}</span>
<span className="text-zinc-400 dark:text-zinc-500">({pctText(100 - conversion)})</span>
</span>
</div>
)}
</React.Fragment>
) : null;
return (
<React.Fragment key={s.id}>
{labelEl}
{stageEl}
{valueEl}
{connector}
</React.Fragment>
);
})}
</div>
<p id={hintId} className="sr-only">
Use the arrow keys to move between stages.
</p>
<table className="sr-only">
<caption>{label}</caption>
<thead>
<tr>
<th scope="col">Stage</th>
<th scope="col">Count</th>
<th scope="col">Share of first stage</th>
<th scope="col">Conversion from previous</th>
<th scope="col">Drop-off from previous</th>
</tr>
</thead>
<tbody>
{stages.map((s, i) => (
<tr key={s.id}>
<th scope="row">{s.label}</th>
<td>{fmt(s.value)}</td>
<td>{pctText(pct(s.value, first))}</td>
<td>{i > 0 ? pctText(pct(s.value, stages[i - 1].value)) : "—"}</td>
<td>{i > 0 ? fmt(stages[i - 1].value - s.value) : "—"}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<th scope="row">Overall conversion</th>
<td colSpan={4}>{pctText(overall)}</td>
</tr>
</tfoot>
</table>
</div>
);
}