{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stats-band",
  "type": "registry:ui",
  "title": "Stats Band",
  "description": "Headline numbers whose digits spin up once, in turn, when scrolled into view.",
  "categories": [
    "marketing"
  ],
  "dependencies": [
    "@number-flow/react"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/stats-band.tsx",
      "content": "\"use client\";\nimport NumberFlow, { continuous, type Format } from \"@number-flow/react\";\nimport { Children, createContext, use, useEffect, useMemo, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\ntype Phase = \"idle\" | \"armed\" | \"done\";\ntype Align = \"start\" | \"center\";\n\ntype Ctx = { phase: Phase; reduce: boolean; locales: Intl.LocalesArgument; duration: number; stagger: number; align: Align };\nconst StatsBandContext = createContext<Ctx | null>(null);\nconst StatIndexContext = createContext(0);\nconst useStatsBand = () => {\n  const ctx = use(StatsBandContext);\n  if (!ctx) throw new Error(\"Stat must be used inside <StatsBand>\");\n  return ctx;\n};\n\nconst noop = () => () => {};\n// Every digit spins through the values in between, so it reads as counting, not swapping.\nconst plugins = [continuous];\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nfunction subscribeReduced(onChange: () => void) {\n  const mq = window.matchMedia(reducedQuery);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nexport type StatsBandProps = React.ComponentProps<\"div\"> & {\n  /** Left-aligned columns, or each stat centered in its column. */\n  align?: Align;\n  /** Locale for every number. Fixed by default so the server and the browser print the same digits. */\n  locales?: Intl.LocalesArgument;\n  /** Milliseconds each number takes to count up. */\n  duration?: number;\n  /** Milliseconds between one stat starting and the next. */\n  stagger?: number;\n};\n\n/**\n * A row of headline numbers that count up from zero the first time the band\n * scrolls into view, one after another. Numbers that were already on screen at\n * first paint are left alone: they have been read, and rewinding them to zero\n * would take them away. The real values are always in the HTML.\n */\nexport function StatsBand({ align = \"start\", locales = \"en-US\", duration = 1400, stagger = 90, className, children, ...rest }: StatsBandProps) {\n  const reduce = useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n  // True only when mounted in the browser without server HTML (a client-side\n  // navigation or a remount). Then nothing has been painted yet, so it can start at zero.\n  const clientMount = useSyncExternalStore(noop, () => true, () => false);\n  const [phase, setPhase] = useState<Phase>(() => (clientMount && !reduce ? \"armed\" : \"idle\"));\n  const [el, setEl] = useState<HTMLDivElement | null>(null);\n\n  useEffect(() => {\n    if (!el || reduce || phase === \"done\") return;\n    let first = true;\n    const io = new IntersectionObserver(\n      ([e]) => {\n        const inView = e.isIntersecting && e.intersectionRatio >= 0.4;\n        if (phase === \"idle\") {\n          // Hydrated from server HTML: on screen already means read already.\n          if (first && e.isIntersecting) setPhase(\"done\");\n          else if (!e.isIntersecting) setPhase(\"armed\");\n        } else if (inView) setPhase(\"done\");\n        first = false;\n      },\n      { threshold: [0, 0.4] },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, [el, phase, reduce]);\n\n  const ctx = useMemo(() => ({ phase, reduce, locales, duration, stagger, align }), [phase, reduce, locales, duration, stagger, align]);\n\n  return (\n    <StatsBandContext value={ctx}>\n      {/* The wrapper is the container the layout queries; an element can't query its own width. */}\n      <div\n        ref={setEl}\n        data-slot=\"stats-band\"\n        data-state={phase === \"armed\" && !reduce ? \"waiting\" : \"shown\"}\n        className={cn(\"@container/stats min-w-0\", className)}\n        {...rest}\n      >\n        <dl className=\"grid grid-cols-2 gap-x-8 gap-y-10 @[640px]/stats:auto-cols-fr @[640px]/stats:grid-flow-col @[640px]/stats:grid-cols-none @[640px]/stats:gap-x-12\">\n          {Children.toArray(children).map((child, i) => (\n            <StatIndexContext key={i} value={i}>\n              {child}\n            </StatIndexContext>\n          ))}\n        </dl>\n      </div>\n    </StatsBandContext>\n  );\n}\n\nexport type StatProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  value: number;\n  /** What the number counts. Keep it to one line where you can. */\n  label: React.ReactNode;\n  /** Intl.NumberFormat options: { notation: \"compact\" } for 2.4B, { style: \"percent\" }, { style: \"currency\", currency: \"USD\" }. */\n  format?: Format;\n  prefix?: string;\n  suffix?: string;\n  /** A short source or qualifier under the label: \"p50, last 30 days\". */\n  detail?: React.ReactNode;\n};\n\nexport function Stat({ value, label, format, prefix, suffix, detail, className, ...rest }: StatProps) {\n  const { phase, reduce, locales, duration, stagger, align } = useStatsBand();\n  const index = use(StatIndexContext);\n  const armed = phase === \"armed\" && !reduce;\n  const final = useMemo(() => `${prefix ?? \"\"}${new Intl.NumberFormat(locales, format).format(value)}${suffix ?? \"\"}`, [locales, format, value, prefix, suffix]);\n\n  // The counting ease is the library's entrance curve: fast off zero, a long settle into the value.\n  const timing = useMemo(() => {\n    const t: EffectTiming = { duration, delay: index * stagger, easing: `cubic-bezier(${ease.out.join(\",\")})`, fill: \"backwards\" };\n    return { spin: t, transform: t, opacity: { ...t, duration: duration * 0.4 } };\n  }, [duration, stagger, index]);\n\n  return (\n    <div\n      data-slot=\"stat\"\n      className={cn(\n        \"relative flex min-w-0 flex-col gap-1.5\",\n        align === \"center\" && \"items-center text-center\",\n        // Dividers are drawn in the gaps and never cross: vertical between columns, horizontal between rows.\n        \"after:pointer-events-none after:absolute after:inset-y-0 after:w-px after:bg-line after:content-['']\",\n        \"before:pointer-events-none before:absolute before:inset-x-0 before:-top-5 before:h-px before:bg-line before:content-['']\",\n        // Two columns on narrow containers.\n        \"@max-[639px]/stats:even:after:-left-4 @max-[639px]/stats:odd:after:hidden\",\n        \"@max-[639px]/stats:[&:nth-child(-n+2)]:before:hidden\",\n        // One row from 640px.\n        \"@[640px]/stats:before:hidden @[640px]/stats:after:-left-6 @[640px]/stats:first:after:hidden\",\n        className,\n      )}\n      {...rest}\n    >\n      <dt className=\"text-[13px] leading-[1.45] text-pretty text-fg-2\">{label}</dt>\n      <dd className=\"order-first\">\n        <span className=\"sr-only\">{final}</span>\n        <NumberFlow\n          aria-hidden\n          value={armed ? 0 : value}\n          locales={locales}\n          format={format}\n          prefix={prefix}\n          suffix={suffix}\n          trend={1}\n          plugins={plugins}\n          animated={!armed}\n          spinTiming={timing.spin}\n          transformTiming={timing.transform}\n          opacityTiming={timing.opacity}\n          className=\"text-[32px] font-medium leading-none tracking-[-0.035em] text-fg tabular @[640px]/stats:text-[40px]\"\n        />\n      </dd>\n      {detail && <dd className=\"font-mono text-[11px] leading-[1.4] text-fg-3\">{detail}</dd>}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/stats-band"
}
