{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"stats-band","type":"registry:component","title":"Stats band","description":"Numbers that arrive when you do: hairlines draw themselves, then each figure rolls up from zero in turn.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/stats-band.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\n\n/* ─────────────────────────────────────────────────────────\n * STATS BAND: the numbers arrive when you do\n *\n *   waiting   before it's on screen, quiet zeros\n *   arriving  the hairlines between stats draw themselves,\n *             then each figure rolls up from zero, one after\n *             another, as its label rises in\n *   done      the real numbers, rolling again only if they\n *             change\n *\n * Screen readers get the real figures straight away.\n * ───────────────────────────────────────────────────────── */\n\nexport interface Stat {\n  value: number;\n  label: string;\n  /** Intl.NumberFormat options, e.g. { notation: \"compact\" }. */\n  format?: Intl.NumberFormatOptions;\n  prefix?: string;\n  suffix?: string;\n}\n\nexport interface StatsBandProps extends React.HTMLAttributes<HTMLDListElement> {\n  stats: Stat[];\n  /** Time between one stat starting and the next (ms). */\n  stagger?: number;\n  /** Classes for the figures. */\n  numberClassName?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\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(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nexport function StatsBand({\n  stats,\n  stagger = 140,\n  numberClassName = \"text-[40px] font-medium leading-[1.15] tracking-[-0.04em] text-foreground\",\n  className = \"\",\n  ...props\n}: StatsBandProps) {\n  const reduced = useReducedMotion();\n  const ref = React.useRef<HTMLDListElement>(null);\n  const [seen, setSeen] = React.useState(false);\n  const [revealed, setRevealed] = React.useState(0);\n\n  // Start once, the first time most of the band is on screen.\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry.isIntersecting) return;\n        setSeen(true);\n        observer.disconnect();\n      },\n      { threshold: 0.5 }\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  // Then reveal the stats one after another: the lines draw first, the numbers follow.\n  React.useEffect(() => {\n    if (!seen) return;\n    if (reduced) {\n      setRevealed(stats.length);\n      return;\n    }\n    const timers = stats.map((_, i) => window.setTimeout(() => setRevealed((r) => Math.max(r, i + 1)), 240 + i * stagger));\n    return () => timers.forEach((t) => window.clearTimeout(t));\n  }, [seen, reduced, stats, stagger]);\n\n  return (\n    <dl ref={ref} className={`grid grid-cols-2 sm:grid-flow-col sm:grid-cols-none sm:auto-cols-fr ${className}`} {...props}>\n      {stats.map((stat, i) => {\n        const shown = i < revealed;\n        return (\n          <div key={stat.label} className=\"relative flex flex-col-reverse justify-end px-6 py-5\">\n            {/* The hairline before every stat but the first, drawn from the middle out (wide screens). */}\n            {i > 0 && (\n              <span\n                aria-hidden=\"true\"\n                className=\"absolute left-0 top-0 hidden h-full w-px origin-center bg-border sm:block\"\n                style={{ transform: seen || reduced ? \"none\" : \"scaleY(0)\", transition: reduced ? \"none\" : `transform 700ms ${EASE} ${i * 80}ms` }}\n              />\n            )}\n            <dt\n              className=\"mt-1 text-[13px] text-muted-foreground\"\n              style={{\n                opacity: shown ? 1 : 0,\n                transform: shown ? \"none\" : \"translateY(6px)\",\n                transition: reduced ? \"none\" : `opacity 500ms ${EASE} 120ms, transform 600ms ${EASE} 120ms`,\n              }}\n            >\n              {stat.label}\n            </dt>\n            <dd>\n              <span className=\"sr-only\">\n                {stat.prefix}\n                {new Intl.NumberFormat(undefined, stat.format).format(stat.value)}\n                {stat.suffix}\n              </span>\n              <span aria-hidden=\"true\">\n                <NumberRoll value={shown ? stat.value : 0} format={stat.format} prefix={stat.prefix} suffix={stat.suffix} duration={1200} className={numberClassName} />\n              </span>\n            </dd>\n          </div>\n        );\n      })}\n    </dl>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/stats-band"}