{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "funnel",
  "type": "registry:ui",
  "title": "Funnel",
  "description": "How many went on at each step, as rows rather than a trapezoid: a thin 4px rail per step filled to its share of the first, in one data hue, with the label and a mono reading beside it, and between two steps a quiet line that says in words how many went on (\"38% continued\"). The header ends with the overall conversion. Static. Visited, signed up, activated, paid.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/funnel.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/funnel 2026.10.26. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Funnel: how many went on at each step. Visited, signed up, activated, paid. Rows, not a trapezoid: the Meter's\n * thin rail (chart.tsx) once per step, every rail measured against the first step.\n *\n * - Header: the Breakdown's row. The label at the start (small, `--litt-fg`), the overall conversion at the end (tabular,\n *   caption, `--litt-fg-subtle`, tabular): \"4.1% overall\", the last step as a share of the first. A long label truncates;\n *   the reading never does.\n * - Steps: one row per step, in order, in exactly the Meter's anatomy: the label, the reading (the value, then its\n *   share of the first step: \"6,930  38%\"), and under them a 4px rail on fg at 12% filled to that share. No\n *   dividers and no boxes.\n * - Between two steps: one quiet line at the caption size in `--litt-fg-subtle` that says in words how many went on\n *   (\"38% continued\"). Never red, never an arrow or a dash doing the talking. `formatStep` rewrites it.\n * - Hue (R7): one data hue for the whole funnel, blue unless `hue` says otherwise. It is one quantity, so one hue.\n * - Shares: whole percentages, with one decimal under 10% (\"4.1%\") so the small end of a funnel still reads, and\n *   \"<0.1%\" for a step that is there but tiny.\n * - Bad data still draws: a step larger than the one before it fills to its share, held at 100% of the first, and\n *   its line says it is more than the step before. A first step of 0 draws empty rails and no shares.\n * - Semantics: an ordered list named by the label. Each step is a list item whose rail is `role=\"meter\"` named by\n *   the step's label, from 0 to the first step's value, with the value and its share as its text (\"6,930 people,\n *   38% of 18,240\"). The line between steps is plain text at the start of the next item, so it is read in order.\n *   The visible header and every visible reading are hidden from assistive tech, and a visually hidden line says\n *   the overall conversion (\"Signups, 4.1% overall: 748 of 18,240\"), so nothing is read twice.\n * - Motion: none. Static, like the Meter: no entrance, no hover, no focus stop. A value that changes just changes.\n */\nimport { useId } from \"react\";\n\n/** The five data hues, in their fixed order (R7). */\nexport type FunnelHue = \"blue\" | \"amber\" | \"teal\" | \"violet\" | \"rose\";\n\n/** A hue's mark tone fills every rail. */\nconst MARK: Record<FunnelHue, string> = {\n  blue: \"bg-litt-data-blue\",\n  amber: \"bg-litt-data-amber\",\n  teal: \"bg-litt-data-teal\",\n  violet: \"bg-litt-data-violet\",\n  rose: \"bg-litt-data-rose\",\n};\n/** The Meter's track. */\nconst TRACK = \"bg-[color-mix(in_oklab,var(--litt-fg)_12%,transparent)]\";\n\nconst groupDigits = (n: number) => n.toLocaleString(\"en-GB\", { maximumFractionDigits: 2 });\nconst clean = (n: number) => (Number.isFinite(n) ? Math.max(0, n) : 0);\n\n/**\n * A share as the funnel shows it: \"38%\", \"4.1%\" under 10% so the small end still reads, \"<0.1%\" for a step that is\n * there but tiny, and \"0%\" for none.\n */\nconst percent = (share: number) => {\n  if (!(share > 0)) return \"0%\";\n  if (share < 0.001) return \"<0.1%\";\n  if (share < 0.1) return `${(Math.round(share * 1000) / 10).toLocaleString(\"en-GB\", { minimumFractionDigits: 1 })}%`;\n  return `${Math.round(share * 100)}%`;\n};\n\n/** The default line between two steps, in words. */\nconst continued = (value: number, previous: number) =>\n  previous <= 0 ? \"Nothing to continue from\" : value > previous ? `${percent(value / previous)} of the step before` : `${percent(value / previous)} continued`;\n\nexport type FunnelStep = {\n  /** What happened at this step, in a few words: \"Signed up\". It names the step's meter, and truncates when narrow. */\n  label: string;\n  /** How many reached this step, zero or more. Its rail fills to its share of the first step. */\n  value: number;\n};\n\nexport type FunnelProps = {\n  /** What the funnel follows, at the start of the header: \"Signups\". It names the list for assistive tech. */\n  label: string;\n  /** The steps, in the order people go through them. Every rail is measured against the first. */\n  steps: FunnelStep[];\n  /** The one data hue every rail fills with. A funnel is one quantity, so it takes one hue. */\n  hue?: FunnelHue;\n  /** The reading at the end of the header, in tabular figures. Defaults to the last step as a share of the first: \"4.1% overall\". */\n  overallText?: string;\n  /** Turns a value into the text shown and read: `(v) => \\`${v} builds\\``. The default groups thousands. */\n  formatValue?: (value: number) => string;\n  /** What the values count, said after each number to screen readers: \"people\". Pass [\"person\", \"people\"] to say the first for exactly 1. Leave it out when `formatValue` already says it. */\n  unit?: string | [one: string, other: string];\n  /** Writes the line between two steps from this step's value and the one before. The default says \"38% continued\", or \"126% of the step before\" when a step went up. */\n  formatStep?: (value: number, previous: number) => string;\n  /** Extra classes for the funnel, such as a width. */\n  className?: string;\n};\n\n/** How many went on at each step: a thin rail per step against the first, with the step-to-step share in words. Static: it never animates. */\nexport function Funnel({ label, steps, hue = \"blue\", overallText, formatValue, unit, formatStep, className = \"\" }: FunnelProps) {\n  const id = useId();\n  const fmt = formatValue ?? groupDigits;\n  const unitFor = (v: number) => (Array.isArray(unit) ? (v === 1 ? unit[0] : unit[1]) : unit);\n  const withUnit = (v: number) => fmt(v) + (unit ? ` ${unitFor(v)}` : \"\");\n  const between = formatStep ?? continued;\n  const fill = MARK[hue] ?? MARK.blue;\n\n  const rows = steps.map((s) => ({ label: s.label, value: clean(s.value) }));\n  const first = rows.length ? rows[0].value : 0;\n  const last = rows.length ? rows[rows.length - 1].value : 0;\n  const measured = first > 0;\n  /* The overall conversion only means something with two steps and a first step to measure against. */\n  const overall = rows.length > 1 && measured ? percent(last / first) + \" overall\" : \"\";\n  const reading = overallText ?? overall;\n  const spoken =\n    overallText != null\n      ? `${label}, ${overallText}`\n      : overall\n        ? `${label}, ${overall}: ${fmt(last)} of ${withUnit(first)}`\n        : \"\";\n\n  return (\n    <div className={\"grid grid-cols-1 content-start gap-6 \" + className}>\n      <div aria-hidden=\"true\" className=\"flex items-baseline justify-between gap-4\">\n        <span className=\"min-w-0 truncate text-litt-small text-litt-fg\">{label}</span>\n        {reading && <span className=\"shrink-0 whitespace-nowrap text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">{reading}</span>}\n      </div>\n      {spoken && <p className=\"sr-only\">{spoken}</p>}\n\n      <ol role=\"list\" aria-label={label} className=\"m-0 grid grid-cols-1 list-none gap-3 p-0\">\n        {rows.map((step, i) => {\n          const share = measured ? step.value / first : 0;\n          const nameId = `${id}-${i}`;\n          const text = !measured || i === 0 ? withUnit(step.value) : `${withUnit(step.value)}, ${percent(share)} of ${fmt(first)}`;\n          return (\n            <li key={`${i}-${step.label}`} className=\"grid grid-cols-1 gap-2\">\n              {i > 0 && <p className=\"m-0 pb-1 text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">{between(step.value, rows[i - 1].value)}</p>}\n              <div aria-hidden=\"true\" className=\"flex items-baseline justify-between gap-4\">\n                <span id={nameId} className=\"min-w-0 truncate text-litt-small text-litt-fg\">\n                  {step.label}\n                </span>\n                <span className=\"shrink-0 whitespace-nowrap text-litt-caption font-normal tabular-nums text-litt-fg-subtle\">\n                  {fmt(step.value)}\n                  {measured && <span className=\"ml-2 inline-block min-w-[4ch] text-right\">{percent(share)}</span>}\n                </span>\n              </div>\n              <div\n                role=\"meter\"\n                aria-labelledby={nameId}\n                aria-valuemin={0}\n                aria-valuemax={measured ? first : 1}\n                aria-valuenow={measured ? Math.min(step.value, first) : 0}\n                aria-valuetext={text}\n                className={\"h-1 overflow-hidden rounded-litt-control \" + TRACK}\n              >\n                <div className={\"h-full rounded-litt-control \" + fill} style={{ width: `${Math.round(Math.min(1, share) * 1e5) / 1e3}%` }} />\n              </div>\n            </li>\n          );\n        })}\n      </ol>\n    </div>\n  );\n}\n",
      "target": "components/litt/funnel.tsx"
    }
  ],
  "docs": "Import Funnel from components/litt/funnel. Pass a label and the steps as { label, value } in the order people go through them. Every rail is measured against the first step. hue picks the one data hue (blue by default), unit or formatValue writes the numbers, overallText replaces \"4.1% overall\", and formatStep(value, previous) rewrites the line between steps.",
  "meta": {
    "version": "2026.10.26",
    "released": "2026-10-26",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
