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