A hand-built SVG conversion funnel with exactly computed bezier tapers, measured never-overlapping stage labels, sequential wipe-in, and a drop-off tooltip that isolates the hovered stage.
npx shadcn@latest add @paragon/funnel-chart"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface FunnelStage {
label: string;
value: number;
/** Explicit fill. Defaults to a computed ramp of `color`. */
color?: string;
}
export interface FunnelChartProps extends React.ComponentProps<"div"> {
stages: FunnelStage[];
height?: number;
/** Base hue for the stage ramp when stages carry no explicit color. */
color?: string;
/** Smooth bezier tapers between stages instead of straight trapezoids. */
curved?: boolean;
/** Per-stage conversion labels under the plot. */
showConversion?: boolean;
formatValue?: (value: number) => string;
/** Accessible description of the chart. */
label?: string;
/** Renders the final state immediately, no sequential reveal. */
static?: boolean;
}
const pct = (v: number) =>
`${(v * 100).toLocaleString("en-US", { maximumFractionDigits: 1 })}%`;
/**
* A hand-built SVG conversion funnel. Segment tapers are computed exactly
* from the stage values (bezier or straight trapezoids), and every label —
* stage name, count, per-stage conversion — is measured against its segment
* with `getComputedTextLength` after layout, fading out rather than ever
* overlapping a neighbor. Stages wipe in sequentially once on first view via
* clip-path; hovering or focusing (arrows, Home, End) isolates a stage and
* opens a cursor-anchored tooltip with overall conversion and drop-off from
* the previous stage. Reduced motion renders the final state.
*/
export function FunnelChart({
stages,
height = 240,
color = "oklch(0.585 0.17 260)",
curved = true,
showConversion = true,
formatValue = (v) => v.toLocaleString("en-US"),
label,
static: isStatic = false,
className,
...props
}: FunnelChartProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
const reducedMotion = useReducedMotion();
const inView = useInView(containerRef, {
once: true,
margin: "0px 0px -48px 0px",
});
const [tip, setTip] = React.useState({ index: 0, x: 0, y: 0, visible: false });
// Which labels actually fit their segment — measured, never estimated.
const [fits, setFits] = React.useState<Record<string, boolean>>({});
const textRefs = React.useRef(new Map<string, SVGTextElement>());
React.useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) =>
setWidth(entry.contentRect.width),
);
observer.observe(el);
return () => observer.disconnect();
}, []);
const animate = !isStatic && !reducedMotion;
const drawn = !animate || inView;
const n = stages.length;
const hasData = n > 0 && stages.some((s) => s.value > 0);
const pad = { top: 42, right: 4, bottom: showConversion ? 24 : 8, left: 4 };
const innerW = Math.max(width - pad.left - pad.right, 0);
const plotBottom = height - pad.bottom;
const innerH = plotBottom - pad.top;
const gap = 2;
const segW = n > 0 ? innerW / n : 0;
const vMax = Math.max(1, ...stages.map((s) => s.value));
const cy = pad.top + innerH / 2;
const xFor = (i: number) => pad.left + i * segW;
const hFor = (v: number) => Math.max((v / vMax) * innerH, 2);
const fillFor = (i: number) =>
stages[i]?.color ??
// Single-hue ramp: strongest at the mouth, fading toward the tail.
`color-mix(in oklch, ${color} ${Math.round(
95 - (n > 1 ? (i / (n - 1)) * 52 : 0),
)}%, var(--color-card))`;
/** Exact segment outline: left edge at v[i], right edge at v[i+1]. */
const pathFor = (i: number) => {
const x0 = xFor(i);
const x1 = x0 + segW - (i < n - 1 ? gap : 0);
const hL = hFor(stages[i].value);
const hR = hFor(stages[Math.min(i + 1, n - 1)].value);
const tl = cy - hL / 2;
const tr = cy - hR / 2;
const br = cy + hR / 2;
const bl = cy + hL / 2;
if (!curved)
return `M${x0} ${tl}L${x1} ${tr}L${x1} ${br}L${x0} ${bl}Z`;
const k = (x1 - x0) * 0.42;
return [
`M${x0} ${tl}`,
`C${x0 + k} ${tl} ${x1 - k} ${tr} ${x1} ${tr}`,
`L${x1} ${br}`,
`C${x1 - k} ${br} ${x0 + k} ${bl} ${x0} ${bl}`,
"Z",
].join("");
};
// Measure every label against its segment budget after layout; only commit
// state when a verdict changes so the effect settles in one pass.
React.useLayoutEffect(() => {
const next: Record<string, boolean> = {};
textRefs.current.forEach((el, key) => {
const budget = Number(el.dataset.budget ?? 0);
next[key] = el.getComputedTextLength() <= budget;
});
setFits((prev) => {
const keys = Object.keys(next);
if (
keys.length === Object.keys(prev).length &&
keys.every((k) => prev[k] === next[k])
)
return prev;
return next;
});
}, [width, height, stages, showConversion]);
const registerText = (key: string) => (el: SVGTextElement | null) => {
if (el) textRefs.current.set(key, el);
else textRefs.current.delete(key);
};
const setIndex = React.useCallback(
(index: number) => {
const i = Math.min(n - 1, Math.max(0, index));
setTip({ index: i, x: xFor(i) + segW / 2, y: cy, visible: true });
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[n, segW, cy, pad.left],
);
const handleMove = (event: React.PointerEvent<SVGSVGElement>) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect || segW <= 0) return;
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
const index = Math.min(n - 1, Math.max(0, Math.floor((px - pad.left) / segW)));
setTip({ index, x: px, y: py, visible: true });
};
const flip = width > 0 && tip.x > width * 0.6;
const flipY = tip.y > height * 0.6;
const hoveredIndex = tip.visible ? tip.index : null;
const convFromPrev = (i: number) =>
i === 0 || stages[i - 1].value <= 0
? 1
: stages[i].value / stages[i - 1].value;
const convOverall = (i: number) =>
stages[0].value > 0 ? stages[i].value / stages[0].value : 0;
const stageDelay = (i: number) =>
`calc(${i * 110}ms * var(--duration-scale, 1))`;
return (
<div
ref={containerRef}
data-slot="funnel-chart"
className={cn("relative w-full", className)}
{...props}
>
{!hasData ? (
<div
style={{ height }}
className="flex flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed text-sm text-muted-foreground"
>
<svg width="28" height="18" viewBox="0 0 28 18" aria-hidden className="opacity-50">
<path d="M1 1h26l-8 8v7l-10-3V9L1 1Z" fill="none" stroke="currentColor" strokeLinejoin="round" />
</svg>
No funnel data
</div>
) : width > 0 ? (
<svg
role="img"
aria-label={
label ?? `Conversion funnel: ${stages.map((s) => s.label).join(", ")}`
}
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="xMidYMid meet"
className="block touch-none overflow-visible"
onPointerMove={handleMove}
onPointerDown={handleMove}
onPointerLeave={(e) => {
if (e.pointerType === "touch") return;
setTip((t) => ({ ...t, visible: false }));
}}
>
{/* Segments — sequential wipe-in, hover isolates */}
{stages.map((s, i) => (
<path
key={i}
d={pathFor(i)}
fill={fillFor(i)}
style={{
fillOpacity:
hoveredIndex === null ? 1 : hoveredIndex === i ? 1 : 0.25,
clipPath: drawn ? "inset(-2% -2% -2% -2%)" : "inset(-2% 102% -2% -2%)",
transition: `fill-opacity 150ms var(--ease-out), clip-path calc(${
animate ? 420 : 0
}ms * var(--duration-scale, 1)) var(--ease-out) ${stageDelay(i)}`,
}}
/>
))}
{/* Stage name + count above each segment (measured fit) */}
{stages.map((s, i) => {
const x = xFor(i) + (segW - gap) / 2;
const budget = Math.max(segW - 12, 0);
const nameFit = fits[`name-${i}`] ?? false;
const valueFit = fits[`value-${i}`] ?? false;
const fade = (fit: boolean, extra = 0) => ({
opacity: fit && drawn ? 1 : 0,
transition: `opacity calc(${animate ? 250 : 0}ms * var(--duration-scale, 1)) var(--ease-out) calc(${
i * 110 + extra
}ms * var(--duration-scale, 1))`,
});
return (
<g key={i}>
<text
ref={registerText(`name-${i}`)}
data-budget={budget}
x={x}
y={12}
textAnchor="middle"
fontSize={10}
className="fill-muted-foreground"
aria-hidden={!nameFit}
style={fade(nameFit)}
>
{s.label}
</text>
<text
ref={registerText(`value-${i}`)}
data-budget={budget}
x={x}
y={30}
textAnchor="middle"
fontSize={13}
fontWeight={600}
className="fill-foreground tabular-nums"
aria-hidden={!valueFit}
style={fade(valueFit, 60)}
>
{formatValue(s.value)}
</text>
</g>
);
})}
{/* Per-stage conversion vs previous, below the plot (measured fit) */}
{showConversion &&
stages.map((s, i) => {
const x = xFor(i) + (segW - gap) / 2;
const fit = fits[`conv-${i}`] ?? false;
return (
<text
key={i}
ref={registerText(`conv-${i}`)}
data-budget={Math.max(segW - 12, 0)}
x={x}
y={height - 8}
textAnchor="middle"
fontSize={10}
className={cn(
"tabular-nums",
i > 0 && convFromPrev(i) < 0.5
? "fill-destructive"
: "fill-muted-foreground",
)}
aria-hidden={!fit}
style={{
opacity: fit && drawn ? 1 : 0,
transition: `opacity calc(${
animate ? 250 : 0
}ms * var(--duration-scale, 1)) var(--ease-out) calc(${
i * 110 + 140
}ms * var(--duration-scale, 1))`,
}}
>
{i === 0 ? "100%" : `${pct(convFromPrev(i))} of prev`}
</text>
);
})}
{/* Keyboard-focusable stage hit targets */}
{segW > 0 &&
stages.map((s, i) => (
<rect
key={i}
x={xFor(i)}
y={pad.top}
width={segW}
height={innerH}
fill="transparent"
tabIndex={0}
role="button"
aria-label={`${s.label}: ${formatValue(s.value)}, ${pct(
convOverall(i),
)} overall${
i > 0 ? `, ${pct(1 - convFromPrev(i))} drop-off from ${stages[i - 1].label}` : ""
}`}
className="cursor-pointer outline-none [&:focus-visible]:stroke-ring"
style={{ strokeWidth: 2 }}
onFocus={() => setIndex(i)}
onBlur={() => setTip((t) => ({ ...t, visible: false }))}
onKeyDown={(e) => {
const target =
e.key === "ArrowRight" || e.key === "ArrowUp"
? Math.min(n - 1, i + 1)
: e.key === "ArrowLeft" || e.key === "ArrowDown"
? Math.max(0, i - 1)
: e.key === "Home"
? 0
: e.key === "End"
? n - 1
: null;
if (target === null) return;
e.preventDefault();
(
e.currentTarget.parentElement?.querySelectorAll(
"rect[role='button']",
)[target] as SVGRectElement | undefined
)?.focus();
}}
/>
))}
</svg>
) : (
<div style={{ height }} />
)}
{/* Cursor tooltip — instant movement, flips at both edges */}
{hasData && (
<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: tip.visible ? 1 : 0,
transform: `translate(${tip.x}px, ${tip.y}px) translate(${
flip ? "calc(-100% - 14px)" : "14px"
}, ${flipY ? "calc(-100% - 12px)" : "10px"})`,
}}
>
<div className="flex items-center gap-1.5">
<span
className="size-2 shrink-0 rounded-full"
style={{ background: fillFor(tip.index) }}
/>
<span className="text-muted-foreground">
{stages[tip.index]?.label}
</span>
<span className="ml-auto pl-3 font-medium tabular-nums">
{formatValue(stages[tip.index]?.value ?? 0)}
</span>
</div>
<div className="mt-1.5 flex flex-col gap-1 border-t pt-1.5">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Overall conversion</span>
<span className="ml-auto pl-3 font-medium tabular-nums">
{pct(convOverall(tip.index))}
</span>
</div>
{tip.index > 0 && (
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">
Drop-off from {stages[tip.index - 1]?.label}
</span>
<span className="ml-auto pl-3 font-medium tabular-nums text-destructive">
−{pct(Math.max(0, 1 - convFromPrev(tip.index)))}
</span>
</div>
)}
</div>
</div>
)}
</div>
);
}