{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"poster-stream","type":"registry:ui","title":"Poster Stream","description":"Posters fly through 3D space in a curving vertical stream you steer by scrolling or dragging inside the container.","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"registry/ui/poster-stream.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PosterStreamProps {\n  images: string[];\n  /** Idle drift in posters per second (negative flies down). */\n  speed?: number;\n  /** Poster width in px. */\n  posterWidth?: number;\n  /** How far the stream bends away from the viewer, 0 to 1. */\n  curve?: number;\n  /** Horizontal scatter in px. */\n  sway?: number;\n  className?: string;\n}\n\nexport function PosterStream({\n  images,\n  speed = 0.35,\n  posterWidth = 200,\n  curve = 0.7,\n  sway = 160,\n  className,\n}: PosterStreamProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const posterRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const cfg = useRef({ speed, curve, sway, posterWidth });\n  useEffect(() => {\n    cfg.current = { speed, curve, sway, posterWidth };\n  }, [speed, curve, sway, posterWidth]);\n\n  useEffect(() => {\n    const root = rootRef.current!;\n    const n = images.length;\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    let offset = 0;\n    let vel = 0;\n    let raf = 0;\n    let last = performance.now();\n    let visible = true;\n    let drag: { y: number; t: number } | null = null;\n\n    const render = () => {\n      const { curve: c, sway: sw } = cfg.current;\n      const h = root.clientHeight;\n      const pw = cfg.current.posterWidth;\n      const pitch = pw * 1.4 * 0.8; // arc length between posters\n      const R = (h * 0.55) / Math.max(c, 0.05); // cylinder radius: smaller = tighter bend\n      for (let i = 0; i < n; i++) {\n        const el = posterRefs.current[i];\n        if (!el) continue;\n        const s = ((((i + offset) % n) + n) % n) - n / 2; // wrapped slot, -n/2..n/2\n        const a = (s * pitch) / R;\n        const y = -Math.sin(a) * R;\n        const z = (Math.cos(a) - 1) * R + Math.cos(i * 1.7) * 90;\n        const x = Math.sin(i * 2.4) * sw + Math.sin(s * 0.8) * sw * 0.25;\n        const edge = Math.max(Math.abs(y) / (h * 0.5 + pw), Math.abs(a) / 1.35);\n        const fade = Math.max(0, Math.min(1, (1 - edge) / 0.25));\n        el.style.transform = `translate3d(${x}px,${y}px,${z}px) rotateX(${(a * 180) / Math.PI}deg) rotateY(${-x / 12}deg) rotateZ(${Math.sin(i * 2.1) * 4}deg)`;\n        el.style.opacity = String(fade);\n        el.style.zIndex = String(Math.round(1000 + z));\n        el.style.visibility = fade <= 0 ? \"hidden\" : \"visible\";\n      }\n    };\n\n    const tick = (now: number) => {\n      const dt = Math.min(0.05, (now - last) / 1000);\n      last = now;\n      if (!drag) {\n        offset += (vel + (reduced ? 0 : cfg.current.speed)) * dt;\n        vel *= Math.exp(-dt * 2.2);\n      }\n      render();\n      raf = visible ? requestAnimationFrame(tick) : 0;\n    };\n    const start = () => {\n      if (raf || !visible) return;\n      last = performance.now();\n      raf = requestAnimationFrame(tick);\n    };\n\n    const unit = () => cfg.current.posterWidth * 1.12; // px of drag per poster\n    const onWheel = (e: WheelEvent) => {\n      e.preventDefault();\n      vel += (e.deltaY * (e.deltaMode === 1 ? 16 : 1)) / 260;\n      vel = Math.max(-12, Math.min(12, vel));\n    };\n    const onDown = (e: PointerEvent) => {\n      root.setPointerCapture(e.pointerId);\n      drag = { y: e.clientY, t: performance.now() };\n      vel = 0;\n    };\n    const onMove = (e: PointerEvent) => {\n      if (!drag) return;\n      const now = performance.now();\n      const d = (drag.y - e.clientY) / unit();\n      offset += d;\n      vel = (d / Math.max(1, now - drag.t)) * 1000 * 0.6;\n      drag = { y: e.clientY, t: now };\n    };\n    const onUp = () => {\n      drag = null;\n    };\n    const onKey = (e: KeyboardEvent) => {\n      const d = e.key === \"ArrowUp\" || e.key === \"ArrowRight\" ? 1 : e.key === \"ArrowDown\" || e.key === \"ArrowLeft\" ? -1 : 0;\n      if (!d) return;\n      e.preventDefault();\n      vel += d * 2.2;\n    };\n\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible) start();\n    });\n    io.observe(root);\n    root.addEventListener(\"wheel\", onWheel, { passive: false });\n    root.addEventListener(\"pointerdown\", onDown);\n    root.addEventListener(\"pointermove\", onMove);\n    root.addEventListener(\"pointerup\", onUp);\n    root.addEventListener(\"pointercancel\", onUp);\n    root.addEventListener(\"keydown\", onKey);\n    render();\n    start();\n    return () => {\n      cancelAnimationFrame(raf);\n      io.disconnect();\n      root.removeEventListener(\"wheel\", onWheel);\n      root.removeEventListener(\"pointerdown\", onDown);\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerup\", onUp);\n      root.removeEventListener(\"pointercancel\", onUp);\n      root.removeEventListener(\"keydown\", onKey);\n    };\n  }, [images.length]);\n\n  return (\n    <div\n      ref={rootRef}\n      tabIndex={0}\n      role=\"region\"\n      aria-roledescription=\"poster stream\"\n      aria-label=\"Poster stream. Scroll, drag or use the arrow keys to fly through.\"\n      className={cn(\n        \"relative h-full w-full cursor-grab touch-none select-none overflow-hidden outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring active:cursor-grabbing\",\n        className,\n      )}\n      style={{ perspective: 900 }}\n    >\n      <div className=\"absolute left-1/2 top-1/2\" style={{ transformStyle: \"preserve-3d\" }}>\n        {images.map((src, i) => (\n          <div\n            key={i}\n            ref={(el) => {\n              posterRefs.current[i] = el;\n            }}\n            className=\"absolute overflow-hidden rounded-lg bg-muted shadow-[0_30px_60px_-15px_rgba(0,0,0,.7)] will-change-transform\"\n            style={{\n              width: posterWidth,\n              height: posterWidth * 1.4,\n              left: -posterWidth / 2,\n              top: -posterWidth * 0.7,\n              visibility: \"hidden\",\n            }}\n          >\n            {/* eslint-disable-next-line @next/next/no-img-element */}\n            <img src={src} alt=\"\" draggable={false} className=\"size-full object-cover\" />\n            <div className=\"absolute inset-0 bg-gradient-to-br from-white/15 via-transparent to-black/30\" />\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n"}]}