Funnel Chart
Charts

Funnel Chart

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.

Install

npx shadcn@latest add @paragon/funnel-chart

funnel-chart.tsx

"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>
  );
}