Fazekit

Code

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

More in Data Display

View all →