{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"stage-bars","type":"registry:component","title":"Stage bars","description":"A funnel you can read at a glance: every stage as a bar against the first, the drop-off between them, and widths that ease when the period changes.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/stage-bars.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\n\n/* ─────────────────────────────────────────────────────────\n * STAGE BARS: a funnel you can read at a glance\n *\n *   arrive    the first time it's on screen the bars grow from\n *             the left one after another and every count and\n *             share rolls up from zero\n *   period    This week, Last week, 30 days: the pill is thrown\n *             to the choice, every bar eases to its new width and\n *             every figure rolls; the drop-off between stages\n *             rolls with them\n *   focus     point at a stage and the others dim, leaving it and\n *             the drop-off into it to read\n *\n * One hue that steps lighter down the funnel, every value\n * labelled on its row, and the whole funnel in a table for\n * screen readers.\n * ───────────────────────────────────────────────────────── */\n\nexport interface Stage {\n  id: string;\n  label: string;\n}\n\nexport interface StagePeriod {\n  id: string;\n  /** In the switch, e.g. \"This week\". */\n  label: string;\n  /** One count per stage, in the same order. */\n  values: number[];\n}\n\nexport interface StageBarsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  title?: string;\n  stages: Stage[];\n  periods: StagePeriod[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (id: string) => void;\n  format?: Intl.NumberFormatOptions;\n  locales?: string | string[];\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst HUE = \"var(--chart-1, var(--primary))\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(\n    subscribeReduced,\n    () => window.matchMedia(reducedQuery).matches,\n    () => false,\n  );\n\nexport function StageBars({\n  title = \"Funnel\",\n  stages,\n  periods,\n  value,\n  defaultValue,\n  onValueChange,\n  format,\n  locales,\n  className = \"\",\n  ...props\n}: StageBarsProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [own, setOwn] = React.useState(defaultValue ?? periods[0]?.id);\n  const periodId = value ?? own;\n  const period = periods.find((p) => p.id === periodId) ?? periods[0];\n  const values = period?.values ?? [];\n  const first = values[0] || 1;\n\n  const [visible, setVisible] = React.useState(false);\n  const [focused, setFocused] = React.useState<number | null>(null);\n  const [pill, setPill] = React.useState({ left: 0, width: 0, ready: false });\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const chipRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n\n  React.useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) {\n          setVisible(true);\n          observer.disconnect();\n        }\n      },\n      { rootMargin: \"0px 0px -10% 0px\" },\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useLayoutEffect(() => {\n    const el = chipRefs.current[periods.findIndex((p) => p.id === periodId)];\n    if (!el) return;\n    setPill((p) => (p.left === el.offsetLeft && p.width === el.offsetWidth ? p : { left: el.offsetLeft, width: el.offsetWidth, ready: p.width > 0 }));\n  }, [periodId, periods]);\n\n  const choose = (next: string) => {\n    if (next === periodId) return;\n    if (value === undefined) setOwn(next);\n    onValueChange?.(next);\n  };\n\n  const fmt = (v: number) => new Intl.NumberFormat(locales, format).format(v);\n  const overall = values.length > 1 ? (values[values.length - 1] ?? 0) / first : 1;\n\n  return (\n    <div ref={rootRef} className={`@container w-full rounded-[22px] bg-background shadow-[0_0_0_1px_var(--border)] ${className}`} {...props}>\n      <div className=\"p-4 @md:p-5 [--gutter:30px] @md:[--gutter:40px]\">\n        {/* The title and its control share a row; the figures sit under both. */}\n        <div className=\"flex flex-wrap items-center justify-between gap-x-3 gap-y-2\">\n          <p className=\"text-[12px] text-muted-foreground @md:text-[13px]\">{title}</p>\n          {periods.length > 1 && (\n            <div role=\"radiogroup\" aria-label=\"Period\" className=\"relative -my-1 inline-flex shrink-0 rounded-full bg-muted p-0.5\">\n              <span\n                aria-hidden=\"true\"\n                className=\"absolute inset-y-0.5 left-0 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]\"\n                style={{\n                  width: pill.width,\n                  transform: `translateX(${pill.left}px)`,\n                  transition: pill.ready && !reduced ? `transform 460ms ${THROW}, width 380ms ${EASE}` : \"none\",\n                }}\n              />\n              {periods.map((p, i) => {\n                const selected = p.id === periodId;\n                return (\n                  <button\n                    key={p.id}\n                    ref={(el) => {\n                      chipRefs.current[i] = el;\n                    }}\n                    type=\"button\"\n                    role=\"radio\"\n                    aria-checked={selected}\n                    tabIndex={selected ? 0 : -1}\n                    onClick={() => choose(p.id)}\n                    onKeyDown={(e) => {\n                      if (e.key !== \"ArrowRight\" && e.key !== \"ArrowLeft\") return;\n                      e.preventDefault();\n                      const next = (i + (e.key === \"ArrowRight\" ? 1 : -1) + periods.length) % periods.length;\n                      choose(periods[next].id);\n                      chipRefs.current[next]?.focus();\n                    }}\n                    className={`relative h-6 whitespace-nowrap rounded-full px-2.5 text-[11.5px] font-medium @md:h-7 @md:px-3 @md:text-[12.5px] transition-colors duration-300 ${FOCUS} ${\n                      selected ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\"\n                    }`}\n                  >\n                    {p.label}\n                  </button>\n                );\n              })}\n            </div>\n          )}\n        </div>\n        <p className=\"mt-0.5 flex items-baseline gap-1.5 text-[22px] font-medium leading-tight tracking-[-0.03em] @md:text-[26px] text-foreground\">\n          <NumberRoll value={visible ? overall : 0} format={{ style: \"percent\", maximumFractionDigits: 1 }} duration={1000} />\n          <span className=\"text-[13px] font-normal tracking-normal text-muted-foreground\">made it all the way</span>\n        </p>\n\n        <ol className=\"mt-4 @md:mt-6\" onPointerLeave={() => setFocused(null)}>\n          {stages.map((s, i) => {\n            const v = values[i] ?? 0;\n            const share = v / first;\n            const step = i > 0 && values[i - 1] ? v / values[i - 1] : 1;\n            const dim = focused !== null && focused !== i;\n            // One hue, a step lighter for each stage further down.\n            const strength = 1 - (stages.length > 1 ? (i / (stages.length - 1)) * 0.5 : 0);\n            return (\n              <li key={s.id}>\n                {i > 0 && (\n                  <p\n                    aria-hidden=\"true\"\n                    className=\"flex h-5 items-center gap-1 pl-1 text-[10.5px] tabular-nums @md:h-6 @md:text-[11.5px] text-muted-foreground transition-opacity duration-300\"\n                    style={{ opacity: dim && focused !== i - 1 ? 0.35 : 1 }}\n                  >\n                    <svg viewBox=\"0 0 12 12\" className=\"size-3\" aria-hidden=\"true\">\n                      <path d=\"M6 2.5v7M3 6.6 6 9.5l3-2.9\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n                    </svg>\n                    <NumberRoll value={visible ? step : 0} format={{ style: \"percent\", maximumFractionDigits: 0 }} duration={800} />\n                    <span>carried on</span>\n                  </p>\n                )}\n                <div onPointerEnter={() => setFocused(i)} className={`transition-opacity duration-300 ${dim ? \"opacity-40\" : \"\"}`}>\n                  <div className=\"flex items-baseline justify-between gap-3 text-[12.5px] @md:text-[13px]\">\n                    <span className=\"truncate text-foreground\">{s.label}</span>\n                    <span className=\"flex shrink-0 items-baseline gap-2 tabular-nums\">\n                      <span className=\"font-medium text-foreground\">\n                        <NumberRoll value={visible ? v : 0} format={format} locales={locales} duration={900} />\n                      </span>\n                      <span className=\"w-10 text-right text-[11px] text-muted-foreground @md:w-11 @md:text-[12px]\">\n                        <NumberRoll value={visible ? share : 0} format={{ style: \"percent\", maximumFractionDigits: share < 0.1 ? 1 : 0 }} duration={900} />\n                      </span>\n                    </span>\n                  </div>\n                  <div className=\"relative mt-1 h-3.5 @md:mt-1.5 @md:h-[18px]\">\n                    <div\n                      aria-hidden=\"true\"\n                      className=\"absolute inset-y-0 left-0 rounded-r-[4px]\"\n                      style={{\n                        width: `${visible ? Math.max(0.5, share * 100) : 0}%`,\n                        background: `color-mix(in oklab, ${HUE} ${Math.round(strength * 100)}%, var(--background))`,\n                        transition: reduced ? \"none\" : `width 820ms ${EASE} ${visible ? i * 90 : 0}ms`,\n                      }}\n                    />\n                  </div>\n                </div>\n              </li>\n            );\n          })}\n        </ol>\n\n        <table className=\"sr-only\">\n          <caption>\n            {title}, {period?.label}: {Math.round(overall * 1000) / 10}% made it from {stages[0]?.label.toLowerCase()} to{\" \"}\n            {stages[stages.length - 1]?.label.toLowerCase()}\n          </caption>\n          <thead>\n            <tr>\n              <th scope=\"col\">Stage</th>\n              <th scope=\"col\">Count</th>\n              <th scope=\"col\">Share of the first stage</th>\n              <th scope=\"col\">Share of the stage before</th>\n            </tr>\n          </thead>\n          <tbody>\n            {stages.map((s, i) => (\n              <tr key={s.id} id={`${id}-${s.id}`}>\n                <th scope=\"row\">{s.label}</th>\n                <td>{fmt(values[i] ?? 0)}</td>\n                <td>{Math.round(((values[i] ?? 0) / first) * 1000) / 10}%</td>\n                <td>{i > 0 && values[i - 1] ? `${Math.round(((values[i] ?? 0) / values[i - 1]) * 1000) / 10}%` : \"–\"}</td>\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/stage-bars"}