{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "funnel-chart",
  "title": "Funnel Chart",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A funnel as bars that narrow step by step. Between steps it says how many carried on and how many dropped out, flags the biggest drop, and ends with the overall result as a sentence.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/funnel-chart.tsx",
      "content": "// Ballmac UI: Funnel Chart. https://ui.ballmac.com/components/funnel-chart\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype FunnelStep = {\n  id: string\n  label: string\n  /** How many reached this step. */\n  value: number\n  /** A note under the label, for example \"Viewed the pricing page\". */\n  hint?: string\n}\n\ntype FunnelChartProps = Omit<React.ComponentProps<\"div\">, \"onSelect\" | \"title\"> & {\n  steps: FunnelStep[]\n  title?: string\n  format?: (value: number) => string\n  /** The selected step. Controlled. */\n  selected?: string | null\n  onSelect?: (id: string | null) => void\n}\n\nconst nf = new Intl.NumberFormat(\"en-US\")\nconst pct = (n: number) => `${Math.round(n * 1000) / 10}%`\n\n/**\n * A funnel as a stack of bars that narrow step by step. Between each pair it says how many carried on and how many dropped out, in words, and the biggest drop is\n * flagged. Steps are buttons that report a selection, and a table version is there for screen readers.\n */\nfunction FunnelChart({ steps, title, format = (v) => nf.format(v), selected, onSelect, className, ...props }: FunnelChartProps) {\n  const msg = useMessages()\n  const id = React.useId()\n  const first = steps[0]?.value || 1\n  let worst = -1\n  let worstDrop = 0\n  steps.forEach((s, i) => {\n    if (i === 0) return\n    const drop = steps[i - 1].value - s.value\n    if (drop > worstDrop) {\n      worstDrop = drop\n      worst = i\n    }\n  })\n  const last = steps[steps.length - 1]\n\n  return (\n    <div data-slot=\"funnel-chart\" className={cn(\"grid min-w-0 gap-3\", className)} {...props}>\n      {title && <h3 id={`${id}-t`} className=\"text-sm font-medium\">{title}</h3>}\n      <ol aria-labelledby={title ? `${id}-t` : undefined} className=\"grid grid-cols-[minmax(0,1fr)] gap-0\">\n        {steps.map((s, i) => {\n          const prev = steps[i - 1]\n          const w = Math.max(6, (s.value / first) * 100)\n          const on = selected === s.id\n          const kept = prev && prev.value > 0 ? s.value / prev.value : 1\n          const content = (\n            <>\n              <span className=\"grid min-w-0 gap-0.5\">\n                <span className=\"flex items-baseline gap-2\">\n                  <span className=\"text-xs font-medium tabular-nums text-muted-foreground\">{i + 1}</span>\n                  <span className=\"truncate text-sm font-medium\">{s.label}</span>\n                </span>\n                {s.hint && <span className=\"truncate ps-4 text-xs text-muted-foreground\">{s.hint}</span>}\n              </span>\n              <span className=\"text-end\">\n                <span className=\"block text-lg font-semibold tracking-[-0.02em] tabular-nums\">{format(s.value)}</span>\n                <span className=\"block text-xs text-muted-foreground tabular-nums\">{msg(\"funnel-chart.ofFirst\", \"{percent} of the start\", { percent: pct(s.value / first) })}</span>\n              </span>\n            </>\n          )\n          return (\n            <li key={s.id} className=\"min-w-0\">\n              {prev && (\n                <p className={cn(\"flex flex-wrap items-center gap-x-1.5 gap-y-1 py-1.5 ps-4 text-xs\", i === worst ? \"font-medium text-foreground\" : \"text-muted-foreground\")}>\n                  <ArrowDown aria-hidden=\"true\" className=\"size-3\" />\n                  {msg(\"funnel-chart.kept\", \"{kept} carried on, {lost} dropped out\", { kept: pct(kept), lost: format(prev.value - s.value) })}\n                  {i === worst && <span className=\"rounded-full border border-foreground px-1.5 py-px text-[11px] whitespace-nowrap\">{msg(\"funnel-chart.biggest\", \"Biggest drop\")}</span>}\n                </p>\n              )}\n              <div className=\"relative\">\n                <span aria-hidden=\"true\" className={cn(\"absolute inset-y-0 start-0 overflow-hidden rounded-lg transition-[width] duration-500 motion-reduce:transition-none\", on ? \"bg-foreground/20\" : \"bg-foreground/[0.07]\")} style={{ width: `${w}%` }}>\n                  <span className=\"absolute inset-x-0 bottom-0 h-0.5 bg-foreground\" />\n                </span>\n                {onSelect ? (\n                  <button type=\"button\" aria-pressed={on} onClick={() => onSelect(on ? null : s.id)} className=\"relative flex min-h-14 w-full items-center justify-between gap-4 rounded-lg px-4 py-2 text-start outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\">\n                    {content}\n                  </button>\n                ) : (\n                  <div className=\"relative flex min-h-14 items-center justify-between gap-4 px-4 py-2\">{content}</div>\n                )}\n              </div>\n            </li>\n          )\n        })}\n      </ol>\n      {last && steps.length > 1 && (\n        <p className=\"border-t pt-3 text-sm text-muted-foreground\">\n          {msg(\"funnel-chart.overall\", \"{percent} of everyone who started reached the end ({value} of {first}).\", { percent: pct(last.value / first), value: format(last.value), first: format(first) })}\n        </p>\n      )}\n    </div>\n  )\n}\n\nexport { FunnelChart, type FunnelChartProps, type FunnelStep }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/funnel-chart.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "chart",
      "funnel",
      "conversion",
      "analytics",
      "steps",
      "drop-off",
      "dashboard"
    ],
    "version": "1.0.0",
    "updated": "2026-10-25",
    "tests": true,
    "ai": {
      "summary": "steps is [{ id, label, value, hint? }] from first to last. title, format(value). Between steps: '40% carried on, 600 dropped out', and the biggest drop is tagged in words. onSelect(id | null) with selected makes steps toggle buttons. Ends with 'X% of everyone who started reached the end'.",
      "whenToUse": [
        "Checkout, signup or onboarding conversion",
        "Any process where people drop out between steps"
      ],
      "whenNotToUse": [
        "Steps that can happen in any order; use bar-list",
        "Flow with branches; use flow-sankey"
      ],
      "composesWith": [
        "bar-list",
        "stat-card",
        "kpi-row",
        "flow-sankey"
      ],
      "a11y": [
        {
          "keys": "Steps",
          "action": "An ordered list; drop-off and conversion are text, never only bar length"
        },
        {
          "keys": "Selection",
          "action": "Optional buttons with pressed state"
        },
        {
          "keys": "Biggest drop",
          "action": "Tagged with the words 'Biggest drop'"
        }
      ],
      "customization": [
        "steps and title",
        "format",
        "onSelect and selected"
      ]
    },
    "statesNone": "A presentational chart: the steps are passed in, so it has nothing of its own to load.",
    "examples": [
      "funnel-chart-demo",
      "funnel-chart-static"
    ],
    "url": "https://ui.ballmac.com/components/funnel-chart"
  },
  "categories": [
    "data-display"
  ],
  "type": "registry:ui"
}