{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-carousel-engine",
  "title": "useCarouselEngine",
  "description": "Drag with momentum, trackpad scrolling, snapping and eased glides for a carousel; hands you each frame to draw.",
  "files": [
    {
      "path": "registry/manniche/hooks/use-carousel-engine.ts",
      "content": "import { useEffect, useLayoutEffect, useState, type MouseEvent, type PointerEvent } from 'react'\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\n\nexport type CarouselEngineOptions = {\n  /** How many slides there are. */\n  count: number\n  /** Wrap around from the last slide to the first. */\n  loop?: boolean\n  /** Controlled: the current slide. The carousel shows it, and goes back to it if `onIndexChange` does not update it. */\n  index?: number\n  /** The slide shown first, when uncontrolled. */\n  defaultIndex?: number\n  /** Called when the carousel heads for a new slide: on release, a key or a button, not when the glide ends. */\n  onIndexChange?: (index: number) => void\n  /** The drag direction. `x` also takes sideways wheel and trackpad swipes. */\n  axis?: 'x' | 'y'\n  /** Pixels of drag that move the carousel one slide. Negative turns the drag round, e.g. pull down for the next slide. */\n  step: number\n  /** Come to rest on a whole slide. Off, a fling glides to a stop anywhere. */\n  snap?: boolean\n  /** Draws a frame: the position in slides (fractional while moving) and the speed in slides per second. */\n  onFrame: (position: number, velocity: number) => void\n}\n\nexport type CarouselEngine = {\n  /** The slide the carousel is on or heading to. */\n  index: number\n  /** Go to a slide. In a loop it takes the short way round. */\n  go: (index: number) => void\n  /** Move by a number of slides from where the carousel is heading. */\n  move: (by: number) => void\n  /** Draw the current frame again, e.g. after a resize. */\n  draw: () => void\n  /** Spread on the element that takes the drag. */\n  bind: {\n    ref: (el: HTMLElement | null) => void\n    onPointerDown: (e: PointerEvent<HTMLElement>) => void\n    onPointerMove: (e: PointerEvent<HTMLElement>) => void\n    onPointerUp: (e: PointerEvent<HTMLElement>) => void\n    onPointerCancel: (e: PointerEvent<HTMLElement>) => void\n    onClickCapture: (e: MouseEvent<HTMLElement>) => void\n  }\n}\n\ntype Settings = Required<Pick<CarouselEngineOptions, 'loop' | 'axis' | 'snap' | 'step' | 'onFrame'>> &\n  Pick<CarouselEngineOptions, 'onIndexChange'> & { n: number; reduce: boolean }\n\nconst SLOP = 6 // px before a press becomes a drag\nconst THROW = 0.32 // s: how far a fling carries, as speed × this\nconst SAMPLE = 90 // ms of pointer history used for the release speed\nconst mod = (v: number, n: number) => ((v % n) + n) % n\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))\n// Pixels per slide, kept away from zero but with its sign.\nconst per = (step: number) => (step < 0 ? -1 : 1) * Math.max(Math.abs(step), 1)\n// Past an end the carousel gives less and less, and never more than one slide.\nconst rubber = (over: number) => 1 - 1 / (over * 0.55 + 1)\n\n/**\n * The motion behind the carousels: one position, in slides, that drag, fling, wheel, keys and buttons all move.\n * It runs a frame loop only while something moves and hands each frame to `onFrame`, which writes transforms\n * straight to the DOM, so React renders only when the slide changes. Glides use ease-out-quint: 300 ms for a\n * step, and after a fling a duration that starts at the finger's speed. Under reduced motion every move jumps.\n */\nexport function useCarouselEngine(options: CarouselEngineOptions): CarouselEngine {\n  const { count, loop = false, index, defaultIndex = 0, axis = 'x', snap = true, step, onFrame, onIndexChange } = options\n  const n = Math.max(count, 1)\n  const reduce = useReducedMotion()\n  const [current, setCurrent] = useState(() => clamp(index ?? defaultIndex, 0, n - 1))\n  const [m] = useState(() => machine(current, setCurrent))\n  const [surface, setSurface] = useState<HTMLElement | null>(null)\n\n  // The latest options, for the handlers and the frame loop, which outlive a render.\n  useLayoutEffect(() => {\n    m.configure({ n, loop, axis, snap, step, onFrame, onIndexChange, reduce })\n  })\n\n  // A new count, or a loop turned on or off, puts the carousel back on a slide it has.\n  useEffect(() => m.reseat(), [n, loop, m])\n  // Controlled: follow the index from outside, and go back to it when a move was not taken up. Neither calls\n  // onIndexChange, as the parent already knows.\n  const controlled = index !== undefined\n  const target = clamp(index ?? 0, 0, n - 1)\n  useEffect(() => {\n    if (controlled && target !== m.committed) m.go(target, false)\n  })\n\n  useEffect(() => (surface && axis === 'x' ? m.listenWheel(surface) : undefined), [surface, axis, m])\n  useEffect(() => m.stop, [m])\n\n  return { index: controlled ? target : current, go: m.go, move: m.move, draw: m.draw, bind: { ref: setSurface, ...m.handlers } }\n}\n\ntype Tween = { from: number; to: number; start: number; dur: number }\ntype Drag = { id: number; x: number; y: number; p0: number; active: boolean; samples: [number, number][]; resume: number | null }\n\n// The state and behaviour, made once per carousel and kept for its lifetime.\nfunction machine(start: number, setCurrent: (i: number) => void) {\n  let want = start // where a drag or the wheel puts the carousel\n  let drawn = start // the position on screen\n  let velocity = 0\n  let tween: Tween | null = null\n  let drag: Drag | null = null\n  let raf = 0\n  let last = 0\n  let swallowClick = false\n  let wheelTimer = 0\n\n  const m = {\n    set: null as Settings | null,\n    committed: start,\n    configure: (settings: Settings) => {\n      m.set = settings\n    },\n    go,\n    move,\n    reseat,\n    draw,\n    stop,\n    listenWheel,\n    handlers: {\n      onPointerDown,\n      onPointerMove,\n      onPointerUp: (e: PointerEvent<HTMLElement>) => release(e, false),\n      onPointerCancel: (e: PointerEvent<HTMLElement>) => release(e, true),\n      onClickCapture,\n    },\n  }\n\n  function draw() {\n    m.set?.onFrame(drawn, velocity)\n  }\n\n  function tick(now: number) {\n    raf = 0\n    const o = m.set\n    if (!o) return\n    let p = want\n    if (tween) {\n      const u = clamp((now - tween.start) / tween.dur, 0, 1)\n      p = tween.from + (tween.to - tween.from) * (1 - (1 - u) ** 5)\n      if (u >= 1) tween = null\n    }\n    const dt = Math.max(now - last, 1) / 1000\n    last = now\n    // Smoothed over about 60 ms, so a layout can lean into the speed without jitter.\n    velocity = o.reduce ? 0 : velocity + ((p - drawn) / dt - velocity) * (1 - Math.exp(-dt / 0.06))\n    drawn = p\n    const moving = tween || drag?.active || Math.abs(velocity) > 0.01\n    if (!moving) velocity = 0\n    o.onFrame(p, velocity)\n    if (moving) raf = requestAnimationFrame(tick)\n  }\n\n  function run() {\n    if (raf) return\n    last = performance.now()\n    raf = requestAnimationFrame(tick)\n  }\n\n  function commit(to: number, notify = true) {\n    const o = m.set\n    if (!o) return\n    const i = mod(Math.round(to), o.n)\n    if (i === m.committed) return\n    m.committed = i\n    setCurrent(i)\n    if (notify) o.onIndexChange?.(i)\n  }\n\n  // Glide to a position. With a release speed (slides/s) in the same direction, the glide starts at that speed.\n  function glide(to: number, speed = 0) {\n    want = to\n    const dist = to - drawn\n    if (m.set?.reduce || Math.abs(dist) < 1e-4) tween = null\n    else {\n      const dur = speed && Math.sign(speed) === Math.sign(dist) ? clamp(((5 * Math.abs(dist)) / Math.abs(speed)) * 1000, 220, 900) : 300\n      tween = { from: drawn, to, start: performance.now(), dur }\n    }\n    run()\n  }\n\n  function go(i: number, notify = true) {\n    const o = m.set\n    if (!o || mod(i, o.n) === m.committed) return\n    let to: number\n    if (o.loop) {\n      const at = Math.round(tween?.to ?? want)\n      to = at + mod(i - at + o.n / 2, o.n) - o.n / 2\n      // An even count puts the far slide exactly half way round: go forwards.\n      if (to < at && mod(i - at, o.n) === o.n / 2) to = at + o.n / 2\n    } else to = clamp(i, 0, o.n - 1)\n    glide(to)\n    commit(to, notify)\n  }\n\n  // After the count or the loop changes: jump to the current slide, or the last one if it is gone. A position\n  // wound past the ends of a loop means nothing in the new count, so it is unwound too.\n  function reseat() {\n    const o = m.set\n    if (!o) return\n    const i = clamp(m.committed, 0, o.n - 1)\n    if (want === i && drawn === i && !tween) return\n    tween = null\n    velocity = 0\n    want = drawn = i\n    commit(i)\n    draw()\n  }\n\n  function move(by: number) {\n    const o = m.set\n    if (!o) return\n    let to = Math.round(tween?.to ?? want) + by\n    if (!o.loop) to = clamp(to, 0, o.n - 1)\n    glide(to)\n    commit(to)\n  }\n\n  function stop() {\n    cancelAnimationFrame(raf)\n    clearTimeout(wheelTimer)\n    raf = 0\n    tween = null\n    drag = null\n    velocity = 0\n    // Land where it was heading, so a remount starts settled.\n    drawn = want\n  }\n\n  // Where a position lands once the ends of a carousel that does not loop push back.\n  function give(raw: number) {\n    const o = m.set!\n    if (o.loop) return raw\n    if (raw < 0) return -rubber(-raw)\n    if (raw > o.n - 1) return o.n - 1 + rubber(raw - (o.n - 1))\n    return raw\n  }\n\n  function settle(speed: number, from: number) {\n    const o = m.set!\n    let to = drawn + speed * THROW\n    if (o.snap) {\n      to = Math.round(to)\n      // A quick flick always moves at least one slide.\n      if (Math.abs(speed) > 0.8 && to === Math.round(from)) to += Math.sign(speed)\n    }\n    if (!o.loop) to = clamp(to, 0, o.n - 1)\n    glide(to, speed)\n    commit(to)\n  }\n\n  function onPointerDown(e: PointerEvent<HTMLElement>) {\n    swallowClick = false\n    // A press that left the carousel before it became a drag never got its pointerup.\n    if (drag && !drag.active) drop(drag)\n    if (!e.isPrimary || e.button !== 0 || drag || !m.set || m.set.n < 2) return\n    const resume = tween ? tween.to : null\n    // Catch a moving carousel where it is.\n    if (tween) {\n      tween = null\n      want = drawn\n    }\n    drag = { id: e.pointerId, x: e.clientX, y: e.clientY, p0: drawn, active: false, samples: [[e.timeStamp, drawn]], resume }\n  }\n\n  function onPointerMove(e: PointerEvent<HTMLElement>) {\n    const d = drag\n    const o = m.set\n    if (!d || !o || e.pointerId !== d.id) return\n    // The mouse came back with its button up, so it was let go outside the carousel.\n    if (!d.active && !(e.buttons & 1)) return drop(d)\n    const dx = e.clientX - d.x\n    const dy = e.clientY - d.y\n    const along = o.axis === 'x' ? dx : dy\n    const across = o.axis === 'x' ? dy : dx\n    if (!d.active) {\n      if (Math.abs(along) < SLOP && Math.abs(across) < SLOP) return\n      // Moving across the carousel: leave it to the page.\n      if (Math.abs(across) >= Math.abs(along)) return drop(d)\n      d.active = true\n      // Count from here, so the slop does not make the slides jump.\n      if (o.axis === 'x') d.x += Math.sign(dx) * SLOP\n      else d.y += Math.sign(dy) * SLOP\n      e.currentTarget.setPointerCapture(e.pointerId)\n      e.currentTarget.dataset.dragging = ''\n    }\n    const p = give(d.p0 - (o.axis === 'x' ? e.clientX - d.x : e.clientY - d.y) / per(o.step))\n    want = p\n    d.samples.push([e.timeStamp, p])\n    while (d.samples.length > 2 && e.timeStamp - d.samples[0][0] > SAMPLE) d.samples.shift()\n    run()\n  }\n\n  function release(e: PointerEvent<HTMLElement>, cancelled: boolean) {\n    const d = drag\n    if (!d || e.pointerId !== d.id) return\n    delete e.currentTarget.dataset.dragging\n    if (!d.active) return drop(d)\n    drag = null\n    swallowClick = true\n    let speed = 0\n    const [t0, p0] = d.samples[0]\n    const [t1, p1] = d.samples[d.samples.length - 1]\n    // A finger that stopped before letting go throws nothing.\n    if (!cancelled && t1 > t0 && e.timeStamp - t1 < 60) speed = ((p1 - p0) / (t1 - t0)) * 1000\n    settle(speed, d.p0)\n  }\n\n  // Let go of a press that never became a drag, and finish any glide it caught.\n  function drop(d: Drag) {\n    drag = null\n    if (d.resume !== null) glide(d.resume)\n  }\n\n  // A drag ends with a click on whatever was under the pointer; that click is not meant.\n  function onClickCapture(e: MouseEvent<HTMLElement>) {\n    if (!swallowClick) return\n    swallowClick = false\n    e.preventDefault()\n    e.stopPropagation()\n  }\n\n  // Sideways wheel and trackpad swipes. A vertical wheel is left to the page.\n  function listenWheel(el: HTMLElement) {\n    let base: number | null = null\n    const onWheel = (e: WheelEvent) => {\n      const o = m.set\n      const dx = e.deltaX || (e.shiftKey ? e.deltaY : 0)\n      if (!o || o.n < 2 || Math.abs(dx) <= Math.abs(e.shiftKey ? 0 : e.deltaY)) return\n      e.preventDefault()\n      if (drag?.active) return\n      tween = null\n      base = (base ?? drawn) + (dx * (e.deltaMode === 1 ? 16 : 1)) / per(o.step)\n      if (!o.loop) base = clamp(base, -1.5, o.n + 0.5)\n      want = give(base)\n      run()\n      clearTimeout(wheelTimer)\n      wheelTimer = window.setTimeout(() => {\n        base = null\n        settle(0, drawn)\n      }, 140)\n    }\n    el.addEventListener('wheel', onWheel, { passive: false })\n    return () => {\n      el.removeEventListener('wheel', onWheel)\n      clearTimeout(wheelTimer)\n    }\n  }\n\n  return m\n}\n",
      "type": "registry:hook"
    },
    {
      "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": [
    "hooks"
  ],
  "type": "registry:hook"
}