{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "funnel-1",
  "title": "Funnel 1: conversion funnel",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A conversion funnel: bars sized against the first step, joined by connectors that narrow as people drop away, with share kept and people lost at each step, the biggest leak written out, a group switch and a panel for any chosen step.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/segmented-control.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/funnel-1/funnel-1.tsx",
      "content": "// Ballmac UI: Funnel 1. https://ui.ballmac.com/blocks/funnel-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowDown, Filter, Lightbulb } from \"lucide-react\"\n\nimport { SegmentedControl, SegmentedControlItem } from \"@/components/ballmac/segmented-control\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Funnel1Step = { id: string; name: string; /** People who reached this step. */ count: number }\ntype Funnel1Segment = { id: string; label: string; steps: Funnel1Step[] }\n\ntype Funnel1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    title?: string\n    description?: string\n    /** One list of steps per group of people (all, mobile, from ads). The first is shown at first. */\n    segments?: Funnel1Segment[]\n    /** \"Last 30 days\", already written for people. */\n    period?: string\n    locale?: string\n    /** The step whose details are open at first. */\n    defaultSelectedId?: string\n  }\n\nconst steps = (counts: number[]): Funnel1Step[] => [\"Visited the site\", \"Viewed a product\", \"Added to cart\", \"Started checkout\", \"Paid\"].map((name, i) => ({ id: `s${i}`, name, count: counts[i]! }))\n\nconst sample: Funnel1Segment[] = [\n  { id: \"all\", label: \"Everyone\", steps: steps([48210, 29840, 11620, 6180, 4390]) },\n  { id: \"mobile\", label: \"Mobile\", steps: steps([31470, 17450, 5980, 2540, 1480]) },\n  { id: \"desktop\", label: \"Desktop\", steps: steps([16740, 12390, 5640, 3640, 2910]) },\n]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-5\">\n      {[100, 70, 45, 28, 16].map((w, i) => (\n        <div key={i}>\n          <Skeleton className=\"h-4 w-40\" />\n          <Skeleton className=\"mx-auto mt-3 h-12 rounded-xl\" style={{ width: `${w}%` }} />\n        </div>\n      ))}\n    </div>\n  )\n}\n\n/**\n * A conversion funnel: each step as a bar sized against the first, joined by connectors that narrow as people drop away, with what share\n * continued and how many were lost between steps. The biggest leak is found for you and written out as a sentence, a segment switch compares\n * groups, and choosing a step shows exactly what happened there. Every figure is worked out from the counts.\n */\nfunction Funnel1({\n  title = \"Checkout funnel\",\n  description = \"Where people stop on the way from a first visit to a payment.\",\n  segments = sample,\n  period = \"Last 30 days\",\n  locale = \"en\",\n  defaultSelectedId,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Funnel1Props) {\n  const id = React.useId()\n  const list = state === \"empty\" ? [] : segments\n  const [seg, setSeg] = React.useState(list[0]?.id ?? \"\")\n  const [pick, setPick] = React.useState<string | null>(defaultSelectedId ?? null)\n  const active = list.find((s) => s.id === seg) ?? list[0]\n  const n = (x: number) => new Intl.NumberFormat(locale).format(x)\n\n  const model = React.useMemo(() => {\n    if (!active?.steps.length) return null\n    const st = active.steps\n    const top = st[0]!.count || 1\n    const rows = st.map((s, i) => {\n      const prev = i ? st[i - 1]!.count : s.count\n      return { ...s, share: s.count / top, kept: prev ? s.count / prev : 0, lost: prev - s.count, i }\n    })\n    const leak = rows.slice(1).reduce((b, r) => (!b || r.lost / (rows[r.i - 1]!.count || 1) > b.lost / (rows[b.i - 1]!.count || 1) ? r : b), null as (typeof rows)[number] | null)\n    return { rows, leak, overall: st.at(-1)!.count / top }\n  }, [active])\n\n  const chosen = model?.rows.find((r) => r.id === pick)\n\n  const content = !model ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <Filter className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No funnel data yet\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty\">{emptyState?.description ?? \"Once visitors move through the steps, the funnel appears here.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <div className=\"grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]\">\n      <div className=\"bg-card rounded-2xl border p-5 sm:p-8\">\n        <div className=\"mb-8 flex flex-wrap items-center justify-between gap-4\">\n          <div>\n            <p className=\"text-muted-foreground text-sm\">Overall conversion</p>\n            <p className=\"text-4xl font-semibold tracking-[-0.04em] tabular-nums\">\n              {(model.overall * 100).toFixed(model.overall < 0.1 ? 1 : 0)}%\n              <span className=\"text-muted-foreground ms-3 text-base font-normal tracking-normal\">\n                {n(model.rows.at(-1)!.count)} of {n(model.rows[0]!.count)}\n              </span>\n            </p>\n          </div>\n          {list.length > 1 ? (\n            <SegmentedControl value={seg} onValueChange={(v) => { setSeg(v); setPick(null) }} aria-label=\"Group\" size=\"sm\">\n              {list.map((s) => (\n                <SegmentedControlItem key={s.id} value={s.id}>\n                  {s.label}\n                </SegmentedControlItem>\n              ))}\n            </SegmentedControl>\n          ) : null}\n        </div>\n\n        <ol>\n          {model.rows.map((r, i) => {\n            const next = model.rows[i + 1]\n            const w = Math.max(r.share * 100, 6)\n            const nw = next ? Math.max(next.share * 100, 6) : 0\n            const isLeak = model.leak && next && model.leak.id === next.id\n            return (\n              <li key={r.id}>\n                <button\n                  type=\"button\"\n                  aria-pressed={pick === r.id}\n                  aria-label={`${r.name}: ${n(r.count)} people, ${(r.share * 100).toFixed(1)}% of the first step${r.i ? `, ${(r.kept * 100).toFixed(1)}% of the step before` : \"\"}`}\n                  onClick={() => setPick(pick === r.id ? null : r.id)}\n                  className=\"focus-visible:ring-ring/50 group block w-full rounded-xl text-start outline-none focus-visible:ring-[3px]\"\n                >\n                  <span aria-hidden=\"true\" className=\"mb-2 flex items-baseline justify-between gap-3 text-sm\">\n                    <span className=\"font-medium\">\n                      <span className=\"text-muted-foreground me-2 tabular-nums\">{r.i + 1}</span>\n                      {r.name}\n                    </span>\n                    <span className=\"tabular-nums\">\n                      <span className=\"font-semibold\">{n(r.count)}</span> <span className=\"text-muted-foreground\">· {(r.share * 100).toFixed(r.share < 0.1 ? 1 : 0)}%</span>\n                    </span>\n                  </span>\n                  <span aria-hidden=\"true\" className=\"flex justify-center\">\n                    <span\n                      className={cn(\"block h-12 rounded-xl transition-[width,filter] duration-700 ease-out group-hover:brightness-110 motion-reduce:transition-none\", pick === r.id ? \"ring-foreground ring-2 ring-offset-2\" : \"\")}\n                      style={{ width: `${w}%`, background: `color-mix(in oklab, var(--chart-1) ${100 - i * 14}%, var(--chart-2))` }}\n                    />\n                  </span>\n                </button>\n                {next ? (\n                  <div className=\"relative my-1 flex h-12 items-center justify-center\" aria-hidden=\"true\">\n                    <span\n                      className=\"absolute inset-y-0 start-1/2 -translate-x-1/2 rtl:translate-x-1/2\"\n                      style={{ width: `${w}%`, background: \"color-mix(in oklab, var(--chart-1) 14%, transparent)\", clipPath: `polygon(0 0, 100% 0, ${50 + nw / w * 50}% 100%, ${50 - nw / w * 50}% 100%)` }}\n                    />\n                    <span className={cn(\"bg-card relative flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium tabular-nums\", isLeak && \"border-destructive/50 text-destructive\")}>\n                      <ArrowDown className=\"size-3\" />\n                      {n(next.lost)} lost · {((1 - next.kept) * 100).toFixed(0)}%\n                    </span>\n                  </div>\n                ) : null}\n              </li>\n            )\n          })}\n        </ol>\n      </div>\n\n      <aside aria-label=\"Insights\" className=\"grid gap-4 lg:sticky lg:top-6\">\n        {model.leak ? (\n          <div className=\"bg-card rounded-2xl border p-5\">\n            <h2 className=\"flex items-center gap-2 text-sm font-semibold\">\n              <Lightbulb aria-hidden=\"true\" className=\"size-4 text-amber-500\" />\n              Biggest leak\n            </h2>\n            <p className=\"mt-3 text-pretty\">\n              Between <strong>{model.rows[model.leak.i - 1]!.name}</strong> and <strong>{model.leak.name}</strong>, {((1 - model.leak.kept) * 100).toFixed(0)}% of people stop: {n(model.leak.lost)} {model.leak.lost === 1 ? \"person\" : \"people\"} in {period.toLowerCase()}.\n            </p>\n            <p className=\"text-muted-foreground mt-2 text-sm text-pretty\">Winning back one in ten of them would bring in about {n(Math.round(model.leak.lost / 10 * model.rows.slice(model.leak.i).reduce((p, r, k, a) => (k ? p * (r.kept || 0) : p), 1)))} more at the end.</p>\n          </div>\n        ) : null}\n        <div className=\"bg-card rounded-2xl border p-5\" aria-live=\"polite\">\n          {chosen ? (\n            <>\n              <h2 className=\"text-sm font-semibold\">{chosen.name}</h2>\n              <dl className=\"mt-3 grid gap-2 text-sm\">\n                <div className=\"flex justify-between gap-4\"><dt className=\"text-muted-foreground\">Reached it</dt><dd className=\"font-medium tabular-nums\">{n(chosen.count)}</dd></div>\n                <div className=\"flex justify-between gap-4\"><dt className=\"text-muted-foreground\">Of the first step</dt><dd className=\"font-medium tabular-nums\">{(chosen.share * 100).toFixed(1)}%</dd></div>\n                {chosen.i ? <div className=\"flex justify-between gap-4\"><dt className=\"text-muted-foreground\">Of the step before</dt><dd className=\"font-medium tabular-nums\">{(chosen.kept * 100).toFixed(1)}%</dd></div> : null}\n                {chosen.i ? <div className=\"flex justify-between gap-4\"><dt className=\"text-muted-foreground\">Lost on the way in</dt><dd className=\"font-medium tabular-nums\">{n(chosen.lost)}</dd></div> : null}\n              </dl>\n            </>\n          ) : (\n            <p className=\"text-muted-foreground text-sm text-pretty\">Choose a step to see what happened there.</p>\n          )}\n        </div>\n      </aside>\n    </div>\n  )\n\n  return (\n    <section data-slot=\"funnel-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-5xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8\">\n        <p className=\"text-muted-foreground text-sm font-medium\">{period}</p>\n        <h1 id={`${id}-title`} className=\"mt-1 text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-2 text-pretty\">{description}</p>\n      </header>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading the funnel\" loading={<Skel />} error={{ title: errorState?.title ?? \"The funnel could not be loaded\", description: errorState?.description ?? \"Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Funnel1, type Funnel1Props, type Funnel1Segment, type Funnel1Step }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/funnel-1/funnel-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "funnel",
      "conversion",
      "analytics",
      "drop-off",
      "checkout",
      "growth",
      "steps",
      "dashboard",
      "metrics"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A conversion funnel. <Funnel1 title description period='Last 30 days' segments={[{ id, label, steps: [{ id, name, count }] }]} locale />. Counts must be people who reached each step, first to last. Share of the first step, share kept from the step before, people lost, overall conversion and the biggest leak (the largest proportional drop) are worked out. A segment switch shows when there are two or more groups.",
      "whenToUse": [
        "Analytics for signup, onboarding or checkout flows",
        "Showing where a growth experiment should focus"
      ],
      "whenNotToUse": [
        "Results of an A/B test (use experiments-1)",
        "Trends over time (use a chart)"
      ],
      "composesWith": [
        "experiments-1",
        "app-shell-1",
        "dashboard-1"
      ],
      "a11y": [
        {
          "keys": "Tab, Enter / Space",
          "action": "Each step is a button that says its count, its share of the first step and of the step before; pressing it shows the same in a panel announced politely"
        },
        {
          "keys": "Loss",
          "action": "Written as 'N lost · 38%' beside the connector, and the biggest leak is a full sentence, not only a red outline"
        },
        {
          "keys": "Group",
          "action": "A labelled switch between groups, such as Everyone, Mobile and Desktop"
        }
      ],
      "customization": [
        "Bars use two theme colors blended by step",
        "A step narrower than 6% of the first is drawn at 6% so it stays visible; its true share is written beside it",
        "Connectors mirror in right-to-left"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "funnel-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton bars that narrow like the real funnel, under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No data yet",
        "example": "funnel-1-empty",
        "trigger": "state=\"empty\" (or segments={[]})",
        "note": "Says the funnel appears once visitors move through the steps; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "funnel-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry)."
      }
    ],
    "examples": [
      "funnel-1-demo",
      "funnel-1-single",
      "funnel-1-loading",
      "funnel-1-empty",
      "funnel-1-error"
    ],
    "url": "https://ui.ballmac.com/components/funnel-1"
  },
  "categories": [
    "blocks",
    "funnel"
  ],
  "type": "registry:block"
}