{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "post-carousel",
  "title": "Post carousel",
  "description": "A post with pictures to swipe through: the one in view swings out and tucks in behind while the next comes forward.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/manniche/post-carousel/post-carousel.tsx",
      "content": "import { ChevronLeft, ChevronRight } from 'lucide-react'\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type HTMLAttributes, type KeyboardEvent, type ReactNode } from 'react'\nimport { useCarouselEngine } from '@/registry/manniche/hooks/use-carousel-engine'\nimport { cn } from '@/lib/utils'\n\nexport type PostCarouselProps = Omit<HTMLAttributes<HTMLElement>, 'onChange'> & {\n  /** One picture per slide. An img or an SVG fills the frame edge to edge. */\n  slides: ReactNode[]\n  /** Read by screen readers, e.g. “Post by Ada Lovelace”. */\n  label: string\n  /** The row above the pictures, e.g. an avatar and a name. */\n  header?: ReactNode\n  /** The text under the pictures. */\n  children?: ReactNode\n  /** Picture width over height. */\n  aspect?: number\n  /** Controlled: the picture in view. */\n  index?: number\n  /** The picture in view at first, when uncontrolled. */\n  defaultIndex?: number\n  /** Called when the post heads for a new picture: on release, a key or a button. */\n  onIndexChange?: (index: number) => void\n  labels?: Partial<Record<'previous' | 'next' | 'picture' | 'of' | 'pictures', string>>\n}\n\nconst EN = { previous: 'Previous picture', next: 'Next picture', picture: 'Picture', of: 'of', pictures: 'Pictures' }\nconst DOT = 6 // px, and the gap between dots\nconst TYPING = 'input, textarea, select, [contenteditable=\"\"], [contenteditable=\"true\"]'\n\n/**\n * A post with a row of pictures to swipe through. The pictures lie in a stack: as you swipe, the one in view swings\n * out to one side and tucks in behind, while its neighbour swings out from behind the other side and comes to the\n * front. Drag, swipe or use a trackpad with momentum, the arrow keys, or the buttons that show on hover. A dot\n * follows the position under the pictures. Every frame is written as transform and opacity only. Under reduced\n * motion each move jumps straight to the next picture.\n */\nexport function PostCarousel({\n  slides,\n  label,\n  header,\n  children,\n  aspect = 4 / 5,\n  index,\n  defaultIndex,\n  onIndexChange,\n  labels = {},\n  className,\n  ...rest\n}: PostCarouselProps) {\n  const t = { ...EN, ...labels }\n  const n = slides.length\n  const id = useId()\n\n  const stage = useRef<HTMLDivElement | null>(null)\n  const cards = useRef<(HTMLDivElement | null)[]>([])\n  const dot = useRef<HTMLSpanElement | null>(null)\n  const [width, setWidth] = useState(0)\n  const frame = useRef(0)\n\n  const paint = useCallback(\n    (p: number) => {\n      const w = frame.current\n      if (!w) return\n      for (let i = 0; i < n; i++) {\n        const el = cards.current[i]\n        if (!el) continue\n        const d = i - p\n        if (Math.abs(d) >= 1.5) {\n          el.style.visibility = 'hidden'\n          continue\n        }\n        // Out to the side and back in behind: the swing peaks half way, where the two pictures pass each other.\n        const a = Math.max(-1, Math.min(1, d))\n        const swing = Math.sin(Math.PI * a)\n        const far = Math.abs(a)\n        el.style.visibility = 'visible'\n        el.style.transform =\n          `translate3d(${(swing * w * 0.5).toFixed(2)}px, 0, 0) rotate(${(swing * 3).toFixed(3)}deg)` + ` scale(${(1 - far * 0.12).toFixed(4)})`\n        el.style.opacity = Math.abs(d) > 1 ? ((1.5 - Math.abs(d)) * 2).toFixed(3) : '1'\n        el.style.zIndex = String(100 - Math.round(Math.abs(d) * 50))\n        const fog = el.lastElementChild as HTMLElement\n        fog.style.opacity = (far * 0.45).toFixed(3)\n      }\n      if (dot.current) dot.current.style.transform = `translateX(${(Math.min(Math.max(p, 0), n - 1) * DOT * 2).toFixed(2)}px)`\n    },\n    [n],\n  )\n\n  const engine = useCarouselEngine({ count: n, index, defaultIndex, onIndexChange, step: width || 300, onFrame: paint })\n  const current = engine.index\n\n  // The stage only exists while there are pictures, so watch it again when the first ones arrive.\n  const empty = !n\n  useLayoutEffect(() => {\n    const el = stage.current\n    if (!el) return\n    const ro = new ResizeObserver(([e]) => setWidth(Math.round(e.contentRect.width)))\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [empty])\n\n  // A new size or set of pictures: draw the current frame again.\n  const { draw } = engine\n  useLayoutEffect(() => {\n    frame.current = width\n    draw()\n  }, [draw, paint, width])\n\n  // If focus sat in a picture that just left the front, keep it in the carousel.\n  useEffect(() => {\n    const el = stage.current\n    const active = document.activeElement\n    if (!el || !active || active === el || !el.contains(active)) return\n    if (!cards.current[current]?.contains(active)) el.focus({ preventScroll: true })\n  }, [current])\n\n  const keys = (e: KeyboardEvent<HTMLElement>) => {\n    if (e.defaultPrevented || (e.target as HTMLElement).closest(TYPING)) return\n    if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') engine.move(e.key === 'ArrowRight' ? 1 : -1)\n    else if (e.key === 'Home') engine.go(0)\n    else if (e.key === 'End') engine.go(n - 1)\n    else return\n    e.preventDefault()\n  }\n\n  if (!n) return null\n\n  const atStart = current === 0\n  const atEnd = current === n - 1\n  const button =\n    'absolute top-1/2 z-[200] grid size-11 -translate-y-1/2 cursor-pointer place-items-center [-webkit-tap-highlight-color:transparent] ' +\n    'transition-[opacity,transform] duration-150 ease-out-quint active:scale-[0.96] motion-reduce:transition-none ' +\n    // Shown on hover or focus, always on touch screens; at an end the button stays focusable but fades out unless it has focus.\n    'opacity-0 group-hover/post:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100 ' +\n    'aria-disabled:pointer-events-none aria-disabled:not-focus-visible:opacity-0! aria-disabled:focus-visible:opacity-50 ' +\n    'focus-visible:outline-none [&:focus-visible>span]:outline-2 [&:focus-visible>span]:outline-offset-2 [&:focus-visible>span]:outline-ring'\n  const face = 'grid size-8 place-items-center rounded-full bg-card text-foreground shadow-[0_1px_2px_rgb(0_0_0/0.14),0_4px_12px_-4px_rgb(0_0_0/0.35)]'\n\n  return (\n    <article aria-label={label} className={cn('relative isolate w-full max-w-sm overflow-hidden rounded-[calc(var(--radius)*2+2px)] bg-card text-card-foreground shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)]', className)} {...rest}>\n      {header ? <div className=\"px-4 py-3\">{header}</div> : null}\n\n      <div onKeyDown={keys} className={cn('group/post relative mx-2 overflow-hidden rounded-[calc(var(--radius)+2px)]', !header && 'mt-2', n < 2 && !children && 'mb-2')}>\n        <div\n          {...engine.bind}\n          ref={(el) => {\n            stage.current = el\n            engine.bind.ref(el)\n          }}\n          id={id}\n          role=\"group\"\n          aria-roledescription=\"carousel\"\n          aria-label={t.pictures}\n          tabIndex={0}\n          style={{ aspectRatio: aspect }}\n          className={cn(\n            'relative w-full touch-pan-y overflow-hidden bg-muted select-none [-webkit-tap-highlight-color:transparent]',\n            'cursor-grab data-dragging:cursor-grabbing',\n            'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring',\n          )}\n        >\n          {slides.map((slide, i) => {\n            const on = i === current\n            return (\n              <div\n                key={i}\n                ref={(el) => {\n                  cards.current[i] = el\n                }}\n                role=\"group\"\n                aria-roledescription=\"slide\"\n                aria-label={`${t.picture} ${i + 1} ${t.of} ${n}`}\n                aria-hidden={!on}\n                className={cn(\n                  'absolute inset-0 overflow-hidden bg-muted will-change-transform',\n                  'shadow-[0_1px_2px_rgb(0_0_0/0.08),0_16px_32px_-16px_rgb(0_0_0/0.5)]',\n                  // Hidden until the first frame places it, except the picture in view.\n                  !on && 'invisible',\n                )}\n              >\n                <div\n                  inert={!on}\n                  className=\"absolute inset-0 [&>img]:size-full [&>img]:object-cover [&>img]:[-webkit-user-drag:none] [&>svg]:size-full\"\n                >\n                  {slide}\n                </div>\n                <span aria-hidden className=\"pointer-events-none absolute inset-0 bg-background opacity-0\" />\n              </div>\n            )\n          })}\n        </div>\n\n        {n > 1 ? (\n          <>\n            <p\n              aria-hidden\n              className=\"pointer-events-none absolute top-2.5 right-2.5 z-[200] rounded-full bg-card px-2 py-1 font-mono text-[11px] leading-none font-medium text-foreground tabular-nums shadow-[0_1px_2px_rgb(0_0_0/0.14)]\"\n            >\n              {current + 1}\n              <span className=\"text-muted-foreground\">/{n}</span>\n            </p>\n            {/* aria-disabled, not disabled: a button that has focus keeps it when it reaches the end. */}\n            <button type=\"button\" aria-label={t.previous} aria-controls={id} aria-disabled={atStart} onClick={() => atStart || engine.move(-1)} className={cn(button, 'left-1')}>\n              <span className={face}>\n                <ChevronLeft className=\"size-4\" strokeWidth={2} aria-hidden />\n              </span>\n            </button>\n            <button type=\"button\" aria-label={t.next} aria-controls={id} aria-disabled={atEnd} onClick={() => atEnd || engine.move(1)} className={cn(button, 'right-1')}>\n              <span className={face}>\n                <ChevronRight className=\"size-4\" strokeWidth={2} aria-hidden />\n              </span>\n            </button>\n          </>\n        ) : null}\n      </div>\n\n      {n > 1 ? (\n        <div aria-hidden className=\"flex justify-center py-3\">\n          <div className=\"relative flex\" style={{ gap: DOT }}>\n            {slides.map((_, i) => (\n              <span key={i} className=\"block rounded-full bg-[color-mix(in_oklab,var(--foreground)_14%,transparent)]\" style={{ width: DOT, height: DOT }} />\n            ))}\n            <span ref={dot} className=\"absolute top-0 left-0 block rounded-full bg-primary\" style={{ width: DOT, height: DOT }} />\n          </div>\n        </div>\n      ) : null}\n\n      {children ? <div className={cn('px-4 pb-4', n < 2 && 'pt-3')}>{children}</div> : null}\n\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {`${t.picture} ${current + 1} ${t.of} ${n}`}\n      </p>\n    </article>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "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": [
    "widgets"
  ],
  "type": "registry:component"
}