{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-ticker",
  "title": "Number ticker",
  "description": "Counts up to a number when it scrolls into view. Screen readers hear only the final number.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/manniche/number-ticker/number-ticker.tsx",
      "content": "import { useEffect, useRef, useState } from 'react'\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\nimport { cn } from '@/lib/utils'\n\nexport type NumberTickerProps = {\n  value: number\n  /** Where the count starts the first time. */\n  from?: number\n  /** Milliseconds for one count. */\n  duration?: number\n  /** Passed to Intl.NumberFormat, e.g. \"da-DK\". Defaults to the browser's language. */\n  locale?: string\n  format?: Intl.NumberFormatOptions\n  /** Start counting first when the number scrolls into view. */\n  startOnView?: boolean\n  className?: string\n}\n\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 4)\n\nexport function NumberTicker({\n  value,\n  from = 0,\n  duration = 1200,\n  locale,\n  format,\n  startOnView = true,\n  className,\n}: NumberTickerProps) {\n  const reduced = useReducedMotion()\n  const el = useRef<HTMLSpanElement>(null)\n  const shown = useRef(from)\n  const [display, setDisplay] = useState(from)\n  const [seen, setSeen] = useState(!startOnView)\n\n  useEffect(() => {\n    if (seen || !el.current) return\n    const io = new IntersectionObserver(([e]) => e.isIntersecting && setSeen(true), { threshold: 0.4 })\n    io.observe(el.current)\n    return () => io.disconnect()\n  }, [seen])\n\n  useEffect(() => {\n    // Under reduced motion the final number is shown at once, even before it is in view.\n    if (reduced) {\n      shown.current = value\n      setDisplay(value)\n      return\n    }\n    if (!seen) return\n    const start = shown.current\n    const t0 = performance.now()\n    let frame = 0\n    const tick = (now: number) => {\n      const t = Math.min(1, (now - t0) / duration)\n      const v = start + (value - start) * easeOut(t)\n      shown.current = v\n      setDisplay(v)\n      if (t < 1) frame = requestAnimationFrame(tick)\n    }\n    frame = requestAnimationFrame(tick)\n    return () => cancelAnimationFrame(frame)\n  }, [value, seen, reduced, duration])\n\n  const fmt = new Intl.NumberFormat(locale, { maximumFractionDigits: 0, ...format })\n\n  return (\n    <span ref={el} className={cn('tabular-nums', className)}>\n      {/* Screen readers get the final number, not every step on the way. */}\n      <span aria-hidden>{fmt.format(display)}</span>\n      <span className=\"sr-only\">{fmt.format(value)}</span>\n    </span>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/manniche/hooks/use-reduced-motion.ts",
      "content": "import { useSyncExternalStore } from 'react'\n\nconst QUERY = '(prefers-reduced-motion: reduce)'\n\nfunction subscribe(onChange: () => void) {\n  const mq = window.matchMedia(QUERY)\n  mq.addEventListener('change', onChange)\n  return () => mq.removeEventListener('change', onChange)\n}\n\n/** True when the visitor has asked the system for less motion. */\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(QUERY).matches,\n    () => false,\n  )\n}\n",
      "type": "registry:hook"
    }
  ],
  "categories": [
    "motion"
  ],
  "type": "registry:component"
}