{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "curved-carousel",
  "title": "Curved Carousel",
  "description": "Posters on a 3D cylinder. The first image starts full-bleed and shrinks into the ring, then scrolling (or drag, or auto rotation) turns the cylinder card by card, with color poster fallbacks for missing images.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [
    "utils",
    "https://trytweenly.vercel.app/r/use-reduced-motion.json",
    "https://trytweenly.vercel.app/r/tweenly-theme.json"
  ],
  "files": [
    {
      "path": "registry/new-york/curved-carousel/curved-carousel.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport gsap from \"gsap\"\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\"\nimport { useGSAP } from \"@gsap/react\"\nimport { useReducedMotion } from \"@/registry/new-york/hooks/use-reduced-motion\"\nimport { cn } from \"@/lib/utils\"\n\ngsap.registerPlugin(useGSAP, ScrollTrigger)\n\nexport interface CurvedCarouselImage {\n  /** Image path, e.g. \"/gallery/01.jpg\" from your public folder. */\n  src: string\n  alt?: string\n  /** Shown in the caption and, when the image is missing, as the poster title. */\n  title?: string\n  caption?: string\n}\n\nexport interface CurvedCarouselProps {\n  images: CurvedCarouselImage[]\n  /** \"scroll\" pins and scrubs with the page, \"drag\" spins with pointer drag and inertia, \"auto\" rotates slowly. Default: \"scroll\" */\n  mode?: \"scroll\" | \"drag\" | \"auto\"\n  /** Start with the first image covering the whole section, then shrink it into the ring. Default: true */\n  intro?: boolean\n  /** \"circle\" morphs through a small circle on the way from full-bleed to card. Default: \"rounded\" */\n  introShape?: \"rounded\" | \"circle\"\n  /** Cylinder radius in card widths. Lower curves harder, higher is flatter. Default: 2.2 */\n  radius?: number\n  /** \"inside\" wraps the cards around the viewer, \"outside\" curves them away like a drum. Default: \"inside\" */\n  curve?: \"inside\" | \"outside\"\n  /** Card width as % of the section width (capped by the section height). Default: 24 */\n  cardWidth?: number\n  /** Card aspect ratio, width / height. Default: \"3 / 4\" */\n  cardAspect?: string\n  /** Space between cards along the curve, in px. Default: 24 */\n  gap?: number\n  /** Perspective distance in px. Lower exaggerates depth. Default: 1400 */\n  perspective?: number\n  /** How strongly side cards turn, as a multiple of the cylinder angle. 0 keeps them flat. Default: 1 */\n  tilt?: number\n  /** Cards visible on each side of the center card. Default: 3 */\n  visible?: number\n  /** Darkening of side cards, 0 to 1. Default: 0.55 */\n  dim?: number\n  /** Card corner radius in px. Default: 18 */\n  cardRadius?: number\n  /** Scroll mode: section heights of scrolling for the whole sequence. Default: 6 */\n  scrollLength?: number\n  /** Settle on the nearest card when scrolling or dragging stops. Default: false */\n  snap?: boolean\n  /** Auto mode: cards per second. Default: 0.15 */\n  speed?: number\n  /** Show number, title and caption on the center card. Default: true */\n  showCaptions?: boolean\n  /** Section background. Follows the theme by default. Default: \"var(--background)\" */\n  background?: string\n  /** Section height (any CSS length). 100cqh fills the nearest size container, or the viewport. Default: \"100cqh\" */\n  height?: string\n  /** Scroll container to track in scroll mode. Omit for the window; null waits for it to mount. Default: window */\n  scroller?: HTMLElement | null\n  /** Called when the intro finishes and the carousel takes over. */\n  onIntroComplete?: () => void\n  /** Called with the index of the image that becomes the center card. */\n  onIndexChange?: (index: number) => void\n  className?: string\n}\n\n/** violet · magenta · green · orange · blue · purple · teal */\nconst POSTER_PALETTE = [\"#6b2bd9\", \"#e5197e\", \"#17a34a\", \"#ff6b1a\", \"#2563eb\", \"#b62bd9\", \"#0d9488\"]\n\nconst inOut = gsap.parseEase(\"power4.inOut\")\nconst clamp01 = (v: number) => Math.min(1, Math.max(0, v))\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t\n\nfunction parseAspect(aspect: string) {\n  const [w, h] = aspect.split(\"/\").map((v) => parseFloat(v))\n  return w && h ? w / h : 0.75\n}\n\n/** Image over a solid color poster; the poster shows while loading and whenever the file is missing. */\nfunction CardMedia({ image, index }: { image: CurvedCarouselImage; index: number }) {\n  const ref = useRef<HTMLImageElement>(null)\n  const [failed, setFailed] = useState<string | null>(null)\n\n  // Catch images that failed before hydration attached onError\n  useEffect(() => {\n    const img = ref.current\n    if (!img?.complete || img.naturalWidth !== 0) return\n    const id = requestAnimationFrame(() => setFailed(image.src))\n    return () => cancelAnimationFrame(id)\n  }, [image.src])\n\n  const title = image.title ?? image.alt ?? `No. ${index + 1}`\n\n  return (\n    <>\n      <div\n        className=\"absolute inset-0 flex flex-col justify-start gap-[4cqw] overflow-hidden p-[7cqw] text-white [container-type:inline-size]\"\n        style={{ background: POSTER_PALETTE[index % POSTER_PALETTE.length] }}\n        aria-hidden={!!image.src && failed !== image.src}\n      >\n        <span className=\"relative font-mono text-[6cqw] tracking-[0.08em] opacity-80\">\n          {String(index + 1).padStart(2, \"0\")}\n        </span>\n        <span className=\"relative break-words font-[family-name:var(--font-display)] text-[19cqw] font-bold uppercase leading-[0.86] tracking-[-0.01em]\">\n          {title}\n        </span>\n      </div>\n      {image.src && failed !== image.src && (\n        <img\n          ref={ref}\n          src={image.src}\n          alt={image.alt ?? image.title ?? \"\"}\n          onError={() => setFailed(image.src)}\n          draggable={false}\n          className=\"absolute inset-0 size-full object-cover text-transparent\"\n        />\n      )}\n    </>\n  )\n}\n\nfunction Caption({ image, index, total }: { image: CurvedCarouselImage; index: number; total: number }) {\n  return (\n    <div className=\"pointer-events-none absolute inset-x-0 bottom-0 bg-black/55 p-[clamp(0.75rem,6%,1.25rem)] text-[#ededed]\">\n      <p className=\"font-mono text-[10px] tabular-nums tracking-[0.12em] text-white/60\">\n        {String(index + 1).padStart(2, \"0\")} / {String(total).padStart(2, \"0\")}\n      </p>\n      {image.title && <p className=\"mt-1 text-sm font-semibold leading-tight tracking-tight\">{image.title}</p>}\n      {image.caption && <p className=\"mt-0.5 text-xs leading-snug text-white/65\">{image.caption}</p>}\n    </div>\n  )\n}\n\n/**\n * Posters on a 3D cylinder. In scroll mode the first image starts full-bleed, shrinks into a card\n * as the ring fades in around it, then the cylinder turns card by card while the section is pinned.\n */\nexport function CurvedCarousel({\n  images,\n  mode = \"scroll\",\n  intro = true,\n  introShape = \"rounded\",\n  radius = 2.2,\n  curve = \"inside\",\n  cardWidth = 24,\n  cardAspect = \"3 / 4\",\n  gap = 24,\n  perspective = 1400,\n  tilt = 1,\n  visible = 3,\n  dim = 0.55,\n  cardRadius = 18,\n  scrollLength = 6,\n  snap = false,\n  speed = 0.15,\n  showCaptions = true,\n  background = \"var(--background)\",\n  height = \"100cqh\",\n  scroller,\n  onIntroComplete,\n  onIndexChange,\n  className,\n}: CurvedCarouselProps) {\n  const rootRef = useRef<HTMLElement>(null)\n  const reduced = useReducedMotion()\n  const [size, setSize] = useState({ w: 0, h: 0 })\n  const n = images.length\n\n  // State that survives re-runs (resize, prop tweaks) without re-rendering\n  const posRef = useRef(0)\n  const activeRef = useRef(-1)\n  const introDoneRef = useRef(false)\n  const sequenceRef = useRef(\"\")\n  const callbacks = useRef({ onIntroComplete, onIndexChange })\n  useEffect(() => {\n    callbacks.current = { onIntroComplete, onIndexChange }\n  }, [onIntroComplete, onIndexChange])\n\n  useEffect(() => {\n    const root = rootRef.current\n    if (!root || reduced) return\n    const ro = new ResizeObserver(([entry]) => {\n      const { width, height: h } = entry.contentRect\n      setSize((s) => (Math.abs(s.w - width) < 1 && Math.abs(s.h - h) < 1 ? s : { w: width, h }))\n    })\n    ro.observe(root)\n    return () => ro.disconnect()\n  }, [reduced])\n\n  // Geometry (pure): card size from the section, capped so tall cards still fit\n  const ratio = parseAspect(cardAspect)\n  const cw = size.w\n    ? Math.min(Math.max((size.w * cardWidth) / 100, Math.min(size.w * 0.62, 200)), size.h * 0.74 * ratio)\n    : 0\n  const ch = cw / ratio\n  const ready = cw > 0 && n > 0\n\n  useGSAP(\n    () => {\n      const root = rootRef.current\n      if (!root || !ready || reduced) return\n      if (mode === \"scroll\" && scroller === null) return\n\n      // A new mode or intro setting replays the sequence from the first card\n      const sequence = `${mode}|${intro}|${introShape}|${n}`\n      if (sequenceRef.current !== sequence) {\n        sequenceRef.current = sequence\n        introDoneRef.current = false\n        posRef.current = 0\n      }\n\n      const cards = gsap.utils.toArray<HTMLElement>(\"[data-cc-card]\", root)\n      const media = cards.map((c) => c.querySelector<HTMLElement>(\"[data-cc-media]\"))\n      const shades = cards.map((c) => c.querySelector<HTMLElement>(\"[data-cc-shade]\"))\n      const captions = cards.map((c) => c.querySelector<HTMLElement>(\"[data-cc-caption]\"))\n\n      const W = size.w\n      const H = size.h\n      const step = cw + gap\n      const R = Math.max(radius, 0.4) * cw\n      const stepAngle = step / R\n      const sign = curve === \"inside\" ? 1 : -1\n      const wrap = mode !== \"scroll\" || n > 2 * visible + 1\n      const circle = Math.min(cw, ch) * 0.62\n\n      const render = (pos: number, introP: number) => {\n        // Intro shape for the first card: full-bleed -> (circle) -> card\n        let sx = 1\n        let sy = 1\n        let rv = cardRadius\n        let ringP = 1\n        if (introP < 1) {\n          if (introShape === \"circle\") {\n            const a = inOut(clamp01(introP / 0.5))\n            const b = inOut(clamp01((introP - 0.5) / 0.5))\n            sx = lerp(lerp(W / cw, circle / cw, a), 1, b)\n            sy = lerp(lerp(H / ch, circle / ch, a), 1, b)\n            rv = lerp(lerp(0, circle / 2, a), cardRadius, b)\n            ringP = inOut(clamp01((introP - 0.45) / 0.55))\n          } else {\n            const e = inOut(introP)\n            sx = lerp(W / cw, 1, e)\n            sy = lerp(H / ch, 1, e)\n            rv = lerp(0, cardRadius, e)\n            ringP = inOut(clamp01((introP - 0.25) / 0.75))\n          }\n        }\n\n        for (let i = 0; i < n; i++) {\n          const card = cards[i]\n          let d = i - pos\n          if (wrap) d = ((((d + n / 2) % n) + n) % n) - n / 2\n          const isIntroCard = introP < 1 && i === 0\n          // Neighbours slide in from further out while the intro plays\n          const dd = isIntroCard ? 0 : d * (1 + (1 - ringP) * 0.9)\n          const abs = Math.abs(dd)\n          const angle = dd * stepAngle\n          const depth = sign * R * (1 - Math.cos(angle))\n          let opacity = clamp01(visible + 0.6 - abs) * (isIntroCard ? 1 : ringP)\n          if (Math.abs(angle) > 1.45 || depth > perspective - 80) opacity = 0\n\n          if (opacity <= 0.001) {\n            card.style.visibility = \"hidden\"\n            continue\n          }\n          card.style.visibility = \"visible\"\n          card.style.opacity = String(opacity)\n          card.style.zIndex = String(isIntroCard ? 2000 : 1000 - Math.round(abs * 10))\n\n          const x = R * Math.sin(angle)\n          const ry = ((-sign * angle * 180) / Math.PI) * tilt\n          const s = 1 - Math.min(abs, visible) * 0.04\n          card.style.transform = `perspective(${perspective}px) translate3d(${x}px, 0px, ${depth}px) rotateY(${ry}deg) scale(${s * sx}, ${s * sy})`\n          card.style.borderRadius = isIntroCard ? `${rv / sx}px / ${rv / sy}px` : `${cardRadius}px`\n\n          // Counter-scale the image so it stays undistorted inside a non-uniform frame\n          const m = media[i]\n          if (m) {\n            const k = Math.max(sx, sy)\n            m.style.transform = isIntroCard ? `scale(${k / sx}, ${k / sy})` : \"none\"\n          }\n          const shade = shades[i]\n          if (shade) shade.style.opacity = String(Math.min(0.92, dim * Math.min(abs, 2.5) * 0.7))\n          const cap = captions[i]\n          if (cap) cap.style.opacity = String(isIntroCard ? 0 : clamp01(1 - abs * 1.6))\n        }\n\n        if (introP >= 1 && !introDoneRef.current) {\n          introDoneRef.current = true\n          callbacks.current.onIntroComplete?.()\n        } else if (introP < 1) {\n          introDoneRef.current = false\n        }\n\n        const active = ((Math.round(pos) % n) + n) % n\n        if (active !== activeRef.current) {\n          activeRef.current = active\n          callbacks.current.onIndexChange?.(active)\n        }\n      }\n\n      // ---- Scroll: pinned, scrubbed sequence ---------------------------------\n      if (mode === \"scroll\") {\n        const introFrac = intro ? Math.min(0.4, Math.max(0.12, 1.2 / Math.max(scrollLength, 1))) : 0\n        const hold = 0.04\n        const span = 1 - introFrac - hold\n        const fromProgress = (p: number) => {\n          const introP = intro ? clamp01(p / introFrac) : 1\n          const pos = n > 1 ? clamp01((p - introFrac) / span) * (n - 1) : 0\n          posRef.current = pos\n          render(pos, introP)\n        }\n        const points = [0, ...images.map((_, k) => introFrac + (n > 1 ? (k / (n - 1)) * span : 0)), 1]\n        const state = { p: 0 }\n        fromProgress(0)\n        gsap.to(state, {\n          p: 1,\n          ease: \"none\",\n          onUpdate: () => fromProgress(state.p),\n          scrollTrigger: {\n            trigger: root,\n            scroller: scroller ?? undefined,\n            start: \"top top\",\n            end: () => `+=${root.offsetHeight * Math.max(scrollLength, 1)}`,\n            pin: true,\n            pinSpacing: true,\n            scrub: 0.6,\n            invalidateOnRefresh: true,\n            anticipatePin: 1,\n            snap: snap ? { snapTo: points, duration: { min: 0.25, max: 0.7 }, ease: \"power4.inOut\", delay: 0.08 } : undefined,\n          },\n        })\n        const refresh = window.setTimeout(() => ScrollTrigger.refresh(), 100)\n        return () => window.clearTimeout(refresh)\n      }\n\n      // ---- Drag / auto -------------------------------------------------------\n      const view = { intro: intro && !introDoneRef.current ? 0 : 1 }\n      const draw = () => render(posRef.current, view.intro)\n      draw()\n\n      const cleanups: (() => void)[] = []\n\n      if (view.intro < 1) {\n        const io = new IntersectionObserver(\n          ([entry]) => {\n            if (!entry.isIntersecting) return\n            io.disconnect()\n            gsap.to(view, { intro: 1, duration: introShape === \"circle\" ? 2.2 : 1.6, ease: \"none\", delay: 0.2, onUpdate: draw })\n          },\n          { threshold: 0.35 }\n        )\n        io.observe(root)\n        cleanups.push(() => io.disconnect())\n      }\n\n      if (mode === \"auto\") {\n        let target = speed\n        let current = speed\n        const tick = (_time: number, dt: number) => {\n          if (view.intro < 1) return\n          const sec = Math.min(dt, 64) / 1000\n          current += (target - current) * Math.min(1, sec * 4)\n          posRef.current += current * sec\n          if (posRef.current > n * 1000) posRef.current -= n * 1000\n          draw()\n        }\n        const pause = () => (target = 0)\n        const resume = () => (target = speed)\n        gsap.ticker.add(tick)\n        root.addEventListener(\"pointerenter\", pause)\n        root.addEventListener(\"pointerleave\", resume)\n        root.addEventListener(\"focusin\", pause)\n        root.addEventListener(\"focusout\", resume)\n        cleanups.push(() => {\n          gsap.ticker.remove(tick)\n          root.removeEventListener(\"pointerenter\", pause)\n          root.removeEventListener(\"pointerleave\", resume)\n          root.removeEventListener(\"focusin\", pause)\n          root.removeEventListener(\"focusout\", resume)\n        })\n      } else {\n        const proxy = { pos: posRef.current }\n        let tween: gsap.core.Tween | null = null\n        let dragging = false\n        let startX = 0\n        let startPos = 0\n        let samples: { t: number; pos: number }[] = []\n\n        const glideTo = (to: number, duration: number) => {\n          tween?.kill()\n          proxy.pos = posRef.current\n          tween = gsap.to(proxy, {\n            pos: to,\n            duration,\n            ease: \"power4.out\",\n            onUpdate: () => {\n              posRef.current = proxy.pos\n              draw()\n            },\n          })\n        }\n        const down = (e: PointerEvent) => {\n          if (view.intro < 1 || (e.pointerType === \"mouse\" && e.button !== 0)) return\n          tween?.kill()\n          dragging = true\n          startX = e.clientX\n          startPos = posRef.current\n          samples = [{ t: performance.now(), pos: startPos }]\n          root.setPointerCapture(e.pointerId)\n        }\n        const move = (e: PointerEvent) => {\n          if (!dragging) return\n          posRef.current = startPos - (e.clientX - startX) / step\n          const now = performance.now()\n          samples.push({ t: now, pos: posRef.current })\n          samples = samples.filter((s) => now - s.t < 100)\n          draw()\n        }\n        const up = (e: PointerEvent) => {\n          if (!dragging) return\n          dragging = false\n          if (root.hasPointerCapture(e.pointerId)) root.releasePointerCapture(e.pointerId)\n          const first = samples[0]\n          const last = samples[samples.length - 1]\n          const dt = last && first ? (last.t - first.t) / 1000 : 0\n          const velocity = dt > 0.01 ? (last.pos - first.pos) / dt : 0\n          let to = posRef.current + velocity * 0.35\n          if (snap) to = Math.round(to)\n          glideTo(to, Math.min(1.4, Math.max(0.6, Math.abs(velocity) * 0.25)))\n        }\n        const key = (e: KeyboardEvent) => {\n          if (view.intro < 1) return\n          if (e.key !== \"ArrowRight\" && e.key !== \"ArrowLeft\") return\n          e.preventDefault()\n          const base = tween?.isActive() ? Math.round(Number(tween.vars.pos)) : Math.round(posRef.current)\n          glideTo(base + (e.key === \"ArrowRight\" ? 1 : -1), 0.8)\n        }\n        root.addEventListener(\"pointerdown\", down)\n        root.addEventListener(\"pointermove\", move)\n        root.addEventListener(\"pointerup\", up)\n        root.addEventListener(\"pointercancel\", up)\n        root.addEventListener(\"keydown\", key)\n        cleanups.push(() => {\n          tween?.kill()\n          root.removeEventListener(\"pointerdown\", down)\n          root.removeEventListener(\"pointermove\", move)\n          root.removeEventListener(\"pointerup\", up)\n          root.removeEventListener(\"pointercancel\", up)\n          root.removeEventListener(\"keydown\", key)\n        })\n      }\n\n      return () => cleanups.forEach((fn) => fn())\n    },\n    {\n      scope: rootRef,\n      dependencies: [\n        ready, reduced, mode, size.w, size.h, cw, ch, images, intro, introShape, radius, curve, gap,\n        perspective, tilt, visible, dim, cardRadius, scrollLength, snap, speed, scroller,\n      ],\n      revertOnUpdate: true,\n    }\n  )\n\n  // A pin adds spacing that moves everything below it. Re-sort and refresh all\n  // triggers after this section (re)builds, so later pinned sections recompute\n  // their start instead of pinning too early and overlapping this one.\n  useEffect(() => {\n    const id = requestAnimationFrame(() => {\n      ScrollTrigger.sort()\n      ScrollTrigger.refresh()\n    })\n    return () => cancelAnimationFrame(id)\n  }, [ready, reduced, mode, size.w, size.h, scrollLength, images.length, intro, scroller])\n\n  if (reduced) {\n    // Static, swipeable row: no pin, no 3D, no motion\n    return (\n      <section\n        ref={rootRef}\n        className={cn(\"@container relative w-full overflow-hidden\", className)}\n        style={{ background }}\n        aria-roledescription=\"carousel\"\n      >\n        <div className=\"flex snap-x snap-mandatory gap-4 overflow-x-auto px-[8cqw] py-10 [scrollbar-width:none]\">\n          {images.map((image, i) => (\n            <div\n              key={i}\n              className=\"relative w-[min(64cqw,300px)] shrink-0 snap-center overflow-hidden\"\n              style={{ aspectRatio: cardAspect, borderRadius: cardRadius }}\n              role=\"group\"\n              aria-roledescription=\"slide\"\n              aria-label={`${i + 1} of ${n}`}\n            >\n              <CardMedia image={image} index={i} />\n              {showCaptions && <Caption image={image} index={i} total={n} />}\n            </div>\n          ))}\n        </div>\n      </section>\n    )\n  }\n\n  return (\n    <section\n      ref={rootRef}\n      className={cn(\n        \"@container relative w-full overflow-hidden select-none\",\n        mode === \"drag\" && \"cursor-grab touch-pan-y outline-none active:cursor-grabbing\",\n        className\n      )}\n      style={{ height, background }}\n      aria-roledescription=\"carousel\"\n      tabIndex={mode === \"drag\" ? 0 : undefined}\n    >\n      {ready &&\n        images.map((image, i) => (\n          <div\n            key={i}\n            data-cc-card\n            className=\"absolute left-1/2 top-1/2 overflow-hidden shadow-[0_30px_60px_-24px_rgb(0_0_0/0.7)] will-change-transform\"\n            style={{ width: cw, height: ch, marginLeft: -cw / 2, marginTop: -ch / 2, visibility: \"hidden\" }}\n            role=\"group\"\n            aria-roledescription=\"slide\"\n            aria-label={`${i + 1} of ${n}`}\n          >\n            <div data-cc-media className=\"absolute inset-0 will-change-transform\">\n              <CardMedia image={image} index={i} />\n            </div>\n            <div data-cc-shade className=\"pointer-events-none absolute inset-0 bg-black opacity-0\" />\n            {showCaptions && (\n              <div data-cc-caption className=\"opacity-0\">\n                <Caption image={image} index={i} total={n} />\n              </div>\n            )}\n          </div>\n        ))}\n    </section>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/curved-carousel",
  "type": "registry:component"
}