{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-on-twos",
  "title": "useOnTwos & Motion Hooks",
  "description": "12fps stepped motion hook (83.33ms poses), count-up tween, ResizeObserver size hook, and reduced-motion detection.",
  "files": [
    {
      "path": "hooks/use-on-twos.ts",
      "content": "'use client';\n\nimport * as React from 'react';\n\nexport type MotionMode = 'stepped' | 'smooth' | 'cut';\n\nexport const FRAME_MS = 1000 / 12; // animating \"on twos\": 12 drawings per second (83.33ms per pose)\n\nconst easeOut = (t: number): number => 1 - Math.pow(1 - t, 3);\n\nconst useIsoLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;\n\nexport function useSize(ref: { readonly current: HTMLElement | null }): { w: number; h: number } {\n  const [size, setSize] = React.useState<{ w: number; h: number }>({ w: 0, h: 0 });\n\n  useIsoLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    // Layout size (offsetWidth/Height), not the transformed box, so scaled previews draw correctly.\n    const measure = () => {\n      const w = el.offsetWidth;\n      const h = el.offsetHeight;\n      setSize((s) => (s.w === w && s.h === h ? s : { w, h }));\n    };\n    measure();\n    if (typeof ResizeObserver === 'undefined') return;\n    const ro = new ResizeObserver(measure);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [ref]);\n\n  return size;\n}\n\nconst REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';\n\nfunction subscribeReducedMotion(onStoreChange: () => void): () => void {\n  if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {\n    return () => {};\n  }\n  const q = window.matchMedia(REDUCED_MOTION_QUERY);\n  if (q.addEventListener) {\n    q.addEventListener('change', onStoreChange);\n    return () => q.removeEventListener('change', onStoreChange);\n  }\n  q.addListener(onStoreChange);\n  return () => q.removeListener(onStoreChange);\n}\n\nfunction getReducedMotionSnapshot(): boolean {\n  if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {\n    return false;\n  }\n  return window.matchMedia(REDUCED_MOTION_QUERY).matches;\n}\n\nfunction getReducedMotionServerSnapshot(): boolean {\n  return false;\n}\n\nexport function useReducedMotion(): boolean {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    getReducedMotionSnapshot,\n    getReducedMotionServerSnapshot,\n  );\n}\n\n/**\n * Progress 0..1 that replays whenever `key` changes.\n *\n * Stepped mode samples the cubic-out easing curve at 12 poses per second\n * (`FRAME_MS = 1000 / 12 = 83.33ms` per pose), like an animation exposure sheet\n * (\"on twos\"). Pose 1 (`k = 1`, `t = 1 / frames`) is displayed immediately at\n * `elapsed = 0` (`Math.floor(el / FRAME_MS) + 1`) so the element visibly steps\n * on the exact interaction frame rather than sitting at 0 for 83.33ms, and each\n * subsequent pose is held for a full 83.33ms interval until `frames` poses complete.\n *\n * Only changes to `key` trigger a new tween; changing `duration` or `motion`\n * alone keeps the current resting state. Honors `prefers-reduced-motion: reduce`.\n */\nexport function useOnTwos(\n  key: unknown,\n  duration = 600,\n  motion: MotionMode = 'stepped',\n): number {\n  const reduced = useReducedMotion();\n  const effectiveMotion: MotionMode = reduced ? 'cut' : motion;\n  const [p, setP] = React.useState<number>(1);\n  const prevKeyRef = React.useRef<unknown>(key);\n  const frames = Math.max(2, Math.round(duration / FRAME_MS));\n  const initialP =\n    effectiveMotion === 'cut' ? 1 : effectiveMotion === 'stepped' ? easeOut(1 / frames) : 0;\n\n  useIsoLayoutEffect(() => {\n    if (effectiveMotion === 'cut') {\n      prevKeyRef.current = key;\n      setP(1);\n      return;\n    }\n    if (Object.is(prevKeyRef.current, key)) {\n      setP(1);\n      return;\n    }\n    prevKeyRef.current = key;\n    const start = performance.now();\n    const totalFrames = Math.max(2, Math.round(duration / FRAME_MS));\n    let raf = 0;\n    let last = -1;\n    const tick = (now: number) => {\n      const el = now - start;\n      let t: number;\n      if (effectiveMotion === 'stepped') {\n        const k = Math.min(totalFrames, Math.floor(el / FRAME_MS) + 1);\n        t = k / totalFrames;\n      } else {\n        t = Math.min(1, el / duration);\n      }\n      const v = easeOut(t);\n      if (v !== last) {\n        last = v;\n        setP(v);\n      }\n      if (t < 1) raf = requestAnimationFrame(tick);\n    };\n    setP(effectiveMotion === 'stepped' ? easeOut(1 / totalFrames) : 0);\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [key, duration, effectiveMotion]);\n\n  const isNewKey = !Object.is(prevKeyRef.current, key);\n  return isNewKey ? initialP : p;\n}\n\nexport const useTween = useOnTwos;\n\nexport function useCountUp(\n  value: number,\n  motion: MotionMode = 'stepped',\n  duration = 600,\n): number {\n  const prev = React.useRef<number>(value);\n  const from = React.useRef<number>(value);\n  const displayedRef = React.useRef<number>(value);\n  if (!Object.is(prev.current, value)) {\n    from.current = displayedRef.current;\n    prev.current = value;\n  }\n  const p = useOnTwos(value, duration, motion);\n  const current = from.current + (value - from.current) * p;\n  displayedRef.current = current;\n  return current;\n}\n\n/**\n * Becomes true after the first change of `key`, so entrance animations\n * never run on first paint (the page is complete at rest).\n */\nexport function useChanged(key: unknown): boolean {\n  const first = React.useRef<unknown>(key);\n  const [changed, setChanged] = React.useState<boolean>(false);\n  React.useEffect(() => {\n    if (key !== first.current) setChanged(true);\n  }, [key]);\n  return changed;\n}\n",
      "type": "registry:hook",
      "target": "hooks/use-on-twos.ts"
    }
  ],
  "type": "registry:hook"
}