{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "funnel",
  "type": "registry:component",
  "title": "Funnel",
  "description": "A conversion funnel where each bar keeps a hatched ghost of what the step before had, so drop-off reads at a glance; flip between counts and percentages and hover a step for its channel split.",
  "categories": [
    "component",
    "Numbers"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/tabs.json"
  ],
  "files": [
    {
      "path": "registry/devl/funnel.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ArrowDownRightIcon, ClockIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, useMemo, useRef, useState } from \"react\";\nimport { Tabs, TabsList, TabsTab } from \"@/components/ui/tabs\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface FunnelSegment {\n  label: string;\n  value: number;\n}\n\nexport interface FunnelStep {\n  id: string;\n  label: string;\n  value: number;\n  breakdown?: FunnelSegment[];\n  medianTime?: string;\n}\n\nexport type FunnelMode = \"count\" | \"percent\";\n\nexport interface FunnelProps {\n  title?: string;\n  caption?: string;\n  steps?: FunnelStep[];\n  defaultMode?: FunnelMode;\n  segmentColors?: string[];\n  locale?: string;\n  className?: string;\n}\n\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];\nconst SEGMENT_COLORS = [\"var(--color-violet-500)\", \"var(--color-sky-600)\", \"var(--color-amber-600)\", \"var(--color-rose-600)\"];\nconst TIP_W = 216;\n\nconst split = (value: number, shares: number[]) => {\n  const raw = shares.map((s) => Math.round(value * s));\n  raw[0] += value - raw.reduce((a, b) => a + b, 0);\n  return raw;\n};\nconst channels = (value: number, shares: number[]) =>\n  split(value, shares).map((v, i) => ({ label: [\"Organic\", \"Paid\", \"Referral\"][i], value: v }));\n\nconst defaultSteps: FunnelStep[] = [\n  { id: \"visit\", label: \"Visited pricing\", value: 48210, breakdown: channels(48210, [0.52, 0.34, 0.14]) },\n  { id: \"start\", label: \"Started signup\", value: 18340, breakdown: channels(18340, [0.49, 0.33, 0.18]), medianTime: \"3m\" },\n  { id: \"verify\", label: \"Verified email\", value: 14905, breakdown: channels(14905, [0.5, 0.31, 0.19]), medianTime: \"6m\" },\n  { id: \"workspace\", label: \"Created workspace\", value: 8822, breakdown: channels(8822, [0.53, 0.26, 0.21]), medianTime: \"1.4h\" },\n  { id: \"invite\", label: \"Invited a teammate\", value: 4104, breakdown: channels(4104, [0.55, 0.21, 0.24]), medianTime: \"1.8 days\" },\n  { id: \"paid\", label: \"Upgraded to paid\", value: 2318, breakdown: channels(2318, [0.57, 0.17, 0.26]), medianTime: \"6 days\" },\n];\n\nfunction RollingNumber({ text, className }: { text: string; className?: string }) {\n  const reduce = useReducedMotion();\n  let d = 0;\n  let o = 0;\n  const glyphs = [...text]\n    .reverse()\n    .map((ch) => {\n      const isDigit = ch >= \"0\" && ch <= \"9\";\n      return { ch, key: isDigit ? `d${d++}` : `s${o++}${ch}`, digit: isDigit ? Number(ch) : null };\n    })\n    .reverse();\n  return (\n    <span className={cn(\"relative inline-flex whitespace-nowrap tabular-nums leading-none\", className)}>\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden className=\"inline-flex [--row:1.15em]\">\n        {glyphs.map((g) =>\n          g.digit === null ? (\n            <span className=\"inline-block h-(--row) leading-(--row)\" key={g.key}>\n              {g.ch}\n            </span>\n          ) : (\n            <span\n              className=\"relative inline-block h-(--row) overflow-hidden\"\n              key={g.key}\n              style={{ maskImage: \"linear-gradient(transparent, black 18%, black 82%, transparent)\" }}\n            >\n              <span className=\"invisible block leading-(--row)\">0</span>\n              <motion.span\n                animate={{ y: `${-g.digit * 10}%` }}\n                className=\"absolute inset-x-0 top-0 flex flex-col items-center\"\n                initial={false}\n                transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 260, damping: 30 }}\n              >\n                {DIGITS.map((n) => (\n                  <span className=\"block h-(--row) leading-(--row)\" key={n}>\n                    {n}\n                  </span>\n                ))}\n              </motion.span>\n            </span>\n          ),\n        )}\n      </span>\n    </span>\n  );\n}\n\nexport function Funnel({\n  title = \"Signup funnel\",\n  caption = \"Last 30 days\",\n  steps = defaultSteps,\n  defaultMode = \"count\",\n  segmentColors = SEGMENT_COLORS,\n  locale = \"en-US\",\n  className,\n}: FunnelProps) {\n  const reduce = useReducedMotion();\n  const listRef = useRef<HTMLOListElement>(null);\n  const rowRefs = useRef<(HTMLLIElement | null)[]>([]);\n  const [mode, setMode] = useState<FunnelMode>(defaultMode);\n  const [active, setActive] = useState<number | null>(null);\n  const [tip, setTip] = useState({ x: 0, y: 0, above: false });\n\n  const num = useMemo(() => new Intl.NumberFormat(locale), [locale]);\n  const pct = (v: number, digits = 1) => `${(v * 100).toFixed(digits)}%`;\n  const top = steps[0]?.value || 1;\n  const drops = steps.map((s, i) => (i === 0 ? 0 : 1 - s.value / (steps[i - 1].value || 1)));\n  const worst = drops.reduce((w, d, i) => (d > drops[w] ? i : w), 1);\n  const overall = (steps.at(-1)?.value ?? 0) / top;\n\n  function placeFor(i: number) {\n    const row = rowRefs.current[i];\n    const list = listRef.current;\n    if (!row || !list) return;\n    const bar = (steps[i].value / top) * list.clientWidth;\n    const below = row.offsetTop + row.offsetHeight / 2 < list.clientHeight * 0.6;\n    setTip({\n      x: Math.max(0, Math.min(bar - 24, list.clientWidth - TIP_W)),\n      y: below ? row.offsetTop + row.offsetHeight + 8 : row.offsetTop - 8,\n      above: !below,\n    });\n  }\n\n  function show(i: number) {\n    setActive(i);\n    placeFor(i);\n  }\n\n  function onKeyDown(e: KeyboardEvent<HTMLLIElement>, i: number) {\n    const next =\n      e.key === \"ArrowDown\" ? i + 1 : e.key === \"ArrowUp\" ? i - 1 : e.key === \"Home\" ? 0 : e.key === \"End\" ? steps.length - 1 : null;\n    if (e.key === \"Escape\") setActive(null);\n    if (next === null || next < 0 || next >= steps.length) return;\n    e.preventDefault();\n    rowRefs.current[next]?.focus();\n  }\n\n  const flipY = tip.above;\n  const step = active === null ? null : steps[active];\n\n  if (steps.length === 0) {\n    return (\n      <section\n        aria-label={title}\n        className={cn(\"w-full max-w-[460px] rounded-2xl border bg-card p-5 text-card-foreground shadow-xs/5\", className)}\n      >\n        <h3 className=\"text-muted-foreground text-sm\">{title}</h3>\n        <p className=\"mt-6 mb-4 text-center text-muted-foreground text-xs\">No one has entered this funnel yet.</p>\n      </section>\n    );\n  }\n\n  return (\n    <section\n      aria-label={title}\n      className={cn(\"w-full max-w-[460px] rounded-2xl border bg-card p-5 text-card-foreground shadow-xs/5\", className)}\n    >\n      <header className=\"flex items-start justify-between gap-3\">\n        <div className=\"flex min-w-0 flex-col gap-1.5\">\n          <h3 className=\"text-muted-foreground text-sm\">{title}</h3>\n          <div className=\"flex items-baseline gap-2\">\n            <RollingNumber className=\"font-semibold text-3xl tracking-tight\" text={pct(overall)} />\n            <span className=\"text-muted-foreground text-xs\">end to end</span>\n          </div>\n          <span className=\"text-muted-foreground text-xs tabular-nums\">\n            {num.format(top)} entered · {caption}\n          </span>\n        </div>\n        <Tabs onValueChange={(v) => setMode(v as FunnelMode)} value={mode}>\n          <TabsList aria-label=\"Show values as\">\n            <TabsTab className=\"px-2 text-xs sm:text-xs\" value=\"count\">\n              Count\n            </TabsTab>\n            <TabsTab className=\"px-2 text-xs sm:text-xs\" value=\"percent\">\n              %\n            </TabsTab>\n          </TabsList>\n        </Tabs>\n      </header>\n\n      <div className=\"relative mt-5\">\n        <ol\n          aria-label={`${title} steps`}\n          className=\"flex flex-col gap-2.5\"\n          onPointerLeave={() => setActive(null)}\n          ref={listRef}\n        >\n          {steps.map((s, i) => {\n            const share = s.value / top;\n            const prevShare = i === 0 ? share : steps[i - 1].value / top;\n            const lost = i === 0 ? 0 : steps[i - 1].value - s.value;\n            const dim = active !== null && active !== i;\n            const value = mode === \"count\" ? num.format(s.value) : pct(share);\n            return (\n              <li\n                aria-label={`${i + 1}. ${s.label}: ${num.format(s.value)}, ${pct(share)} of entrants${i ? `, ${pct(drops[i])} dropped from the previous step` : \"\"}`}\n                className=\"group/step relative cursor-default rounded-lg outline-none ring-offset-4 ring-offset-card focus-visible:ring-2 focus-visible:ring-ring\"\n                key={s.id}\n                onBlur={() => setActive(null)}\n                onFocus={() => show(i)}\n                onKeyDown={(e) => onKeyDown(e, i)}\n                onPointerEnter={() => show(i)}\n                ref={(el) => {\n                  rowRefs.current[i] = el;\n                }}\n                tabIndex={0}\n              >\n                <div\n                  className={cn(\n                    \"mb-1.5 flex items-baseline justify-between gap-3 text-xs transition-opacity duration-150\",\n                    dim && \"opacity-50\",\n                  )}\n                >\n                  <span className=\"flex min-w-0 items-baseline gap-2\">\n                    <span className=\"w-3 shrink-0 text-muted-foreground tabular-nums\">{i + 1}</span>\n                    <span className=\"truncate font-medium\">{s.label}</span>\n                  </span>\n                  <RollingNumber className=\"shrink-0 font-medium\" text={value} />\n                </div>\n                <div className=\"relative ms-5 h-5\">\n                  {i > 0 ? (\n                    <motion.div\n                      animate={{ opacity: 1 }}\n                      className={cn(\n                        \"absolute inset-y-0 flex items-center justify-end rounded-e-md pe-1.5 transition-opacity duration-150\",\n                        dim && \"opacity-40\",\n                      )}\n                      initial={{ opacity: 0 }}\n                      style={{\n                        left: `${share * 100}%`,\n                        width: `${(prevShare - share) * 100}%`,\n                        backgroundImage:\n                          \"repeating-linear-gradient(135deg, color-mix(in oklab, var(--color-foreground) 13%, transparent) 0 1px, transparent 1px 5px)\",\n                      }}\n                      transition={{ duration: reduce ? 0 : 0.25, delay: reduce ? 0 : 0.35 + i * 0.04 }}\n                    >\n                      {(prevShare - share) * 100 > 14 ? (\n                        <span\n                          className={cn(\n                            \"inline-flex items-center gap-0.5 rounded-sm bg-card/90 px-1 text-[11px] tabular-nums leading-4\",\n                            i === worst ? \"text-destructive-foreground\" : \"text-muted-foreground\",\n                          )}\n                        >\n                          <ArrowDownRightIcon className=\"size-3\" />\n                          {mode === \"count\" ? num.format(lost) : pct(drops[i], 0)}\n                        </span>\n                      ) : null}\n                    </motion.div>\n                  ) : null}\n                  <motion.div\n                    animate={{ clipPath: `inset(0 0% 0 0 round ${i === 0 ? \"6px\" : \"6px 0 0 6px\"})` }}\n                    className={cn(\n                      \"absolute inset-y-0 left-0 bg-primary transition-opacity duration-150\",\n                      i === 0 ? \"rounded-md\" : \"rounded-s-md\",\n                      dim && \"opacity-40\",\n                    )}\n                    initial={{ clipPath: `inset(0 100% 0 0 round ${i === 0 ? \"6px\" : \"6px 0 0 6px\"})` }}\n                    style={{ width: `${Math.max(share * 100, 1.5)}%` }}\n                    transition={reduce ? { duration: 0 } : { duration: 0.5, delay: i * 0.04, ease: [0.23, 1, 0.32, 1] }}\n                  />\n                  {i > 0 && (prevShare - share) * 100 <= 14 ? (\n                    <span\n                      className={cn(\n                        \"absolute top-1/2 inline-flex -translate-y-1/2 items-center gap-0.5 ps-1.5 text-[11px] tabular-nums\",\n                        i === worst ? \"text-destructive-foreground\" : \"text-muted-foreground\",\n                      )}\n                      style={{ left: `${prevShare * 100}%` }}\n                    >\n                      <ArrowDownRightIcon className=\"size-3\" />\n                      {mode === \"count\" ? num.format(lost) : pct(drops[i], 0)}\n                    </span>\n                  ) : null}\n                </div>\n              </li>\n            );\n          })}\n        </ol>\n\n        <AnimatePresence>\n          {step && active !== null ? (\n            <motion.div\n              animate={{ opacity: 1, scale: 1 }}\n              className={cn(\n                \"pointer-events-none absolute z-10 rounded-xl border bg-popover p-3 text-popover-foreground text-xs shadow-lg/5\",\n                flipY && \"-translate-y-full\",\n              )}\n              exit={{ opacity: 0, transition: { duration: reduce ? 0 : 0.1 } }}\n              initial={{ opacity: 0, scale: reduce ? 1 : 0.96 }}\n              style={{\n                width: TIP_W,\n                left: tip.x,\n                top: tip.y,\n                originX: 0,\n                originY: flipY ? 1 : 0,\n              }}\n              transition={{ duration: reduce ? 0 : 0.14, ease: [0.23, 1, 0.32, 1] }}\n            >\n              <div className=\"flex items-baseline justify-between gap-2\">\n                <span className=\"truncate font-medium\">{step.label}</span>\n                <span className=\"shrink-0 text-muted-foreground tabular-nums\">Step {active + 1}</span>\n              </div>\n              <div className=\"mt-2 grid grid-cols-2 gap-2 tabular-nums\">\n                <div className=\"rounded-lg bg-muted px-2 py-1.5\">\n                  <div className=\"text-muted-foreground\">{active ? \"Converted\" : \"Entered\"}</div>\n                  <div className=\"font-medium\">{active ? pct(1 - drops[active]) : num.format(step.value)}</div>\n                </div>\n                <div className=\"rounded-lg bg-muted px-2 py-1.5\">\n                  <div className=\"text-muted-foreground\">{active ? \"Dropped\" : \"Of total\"}</div>\n                  <div className={cn(\"font-medium\", active === worst && \"text-destructive-foreground\")}>\n                    {active ? num.format(steps[active - 1].value - step.value) : \"100%\"}\n                  </div>\n                </div>\n              </div>\n              {step.breakdown?.length ? (\n                <div className=\"mt-2.5 flex flex-col gap-1.5\">\n                  <div className=\"flex h-1.5 gap-0.5 overflow-hidden rounded-full\">\n                    {step.breakdown.map((seg, k) => (\n                      <div\n                        className=\"h-full first:rounded-s-full last:rounded-e-full\"\n                        key={seg.label}\n                        style={{ flexGrow: seg.value, background: segmentColors[k % segmentColors.length] }}\n                      />\n                    ))}\n                  </div>\n                  {step.breakdown.map((seg, k) => (\n                    <div className=\"flex items-center gap-2 tabular-nums\" key={seg.label}>\n                      <span className=\"size-2 rounded-full\" style={{ background: segmentColors[k % segmentColors.length] }} />\n                      <span className=\"text-muted-foreground\">{seg.label}</span>\n                      <span className=\"ms-auto font-medium\">{num.format(seg.value)}</span>\n                      <span className=\"w-10 text-right text-muted-foreground\">{pct(seg.value / (step.value || 1), 0)}</span>\n                    </div>\n                  ))}\n                </div>\n              ) : null}\n              {step.medianTime ? (\n                <div className=\"mt-2.5 flex items-center gap-1.5 border-t pt-2 text-muted-foreground\">\n                  <ClockIcon className=\"size-3\" />\n                  Median wait {step.medianTime}\n                </div>\n              ) : null}\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </div>\n\n      {steps.length > 1 ? (\n        <footer className=\"mt-4 flex items-center gap-1.5 text-muted-foreground text-xs\">\n          <span className=\"inline-block size-2 shrink-0 rounded-full bg-destructive\" />\n          <span className=\"shrink-0\">Biggest drop:</span>\n          <span className=\"truncate font-medium text-foreground\">{steps[worst].label}</span>\n          <span className=\"shrink-0 tabular-nums\">({pct(drops[worst], 0)} lost)</span>\n        </footer>\n      ) : null}\n    </section>\n  );\n}\n"
    }
  ]
}