{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-fan",
  "title": "Image Fan",
  "description": "One image rises from below, then the rest slide out from under it into an overlapping fan.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [
    "https://trytweenly.vercel.app/r/use-reduced-motion.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/image-fan/image-fan.tsx",
      "content": "\"use client\"\n\nimport { useEffect, useRef } 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 ImageFanItem {\n  src: string\n  alt: string\n}\n\nexport type ImageFanDirection = \"right\" | \"left\" | \"center\"\n\nexport interface ImageFanProps {\n  images: ImageFanItem[]\n  /** Which way the stack spreads after the first image rises. Default: \"right\" */\n  direction?: ImageFanDirection\n  /** Speed multiplier. 2 = twice as fast. Default: 1 */\n  speed?: number\n  /** Blur in px while cards are moving in. Default: 10 */\n  blur?: number\n  /** How far the first card rises from, in card heights. Default: 1.2 */\n  rise?: number\n  /** Gap between card centers, as a fraction of card width (auto-shrinks to fit). Default: 0.72 */\n  overlap?: number\n  /** Tilt added per card away from the first, in degrees. Default: 3 */\n  rotate?: number\n  /** How much the row curves downward toward the ends, in px. Default: 6 */\n  arc?: number\n  /** Delay between cards sliding out, in seconds. Default: 0.06 */\n  stagger?: number\n  /** Card width (any CSS length). Default: \"min(20cqw, 220px)\" */\n  cardWidth?: string\n  /** Card aspect ratio. Default: \"3 / 4\" */\n  aspectRatio?: string\n  /** Corner radius in px. Default: 14 */\n  radius?: number\n  /** Lift cards on hover. Default: true */\n  hoverLift?: boolean\n  /** Play only the first time it enters view. Default: true */\n  once?: boolean\n  /** Scroll container to watch instead of the window (selector or element). */\n  scroller?: string | HTMLElement\n  /** Section height (any CSS length). Default: \"28rem\" */\n  height?: string\n  className?: string\n}\n\n// Tinted placeholder shown behind each image (and instead of it if the file is missing)\nconst tint = (i: number) =>\n  `hsl(${(i * 47) % 360} 42% 52% / 0.4)`\nconst hideBroken = (e: React.SyntheticEvent<HTMLImageElement>) => (e.currentTarget.style.visibility = \"hidden\")\n\n/** Final slot for card `i`, as an offset from the center (in \"card steps\"). */\nfunction slotOf(i: number, n: number, direction: ImageFanDirection) {\n  const mid = (n - 1) / 2\n  if (direction === \"right\") return i - mid\n  if (direction === \"left\") return mid - i\n  // center: first card in the middle, then alternate right, left, right…\n  return i === 0 ? 0 : Math.ceil(i / 2) * (i % 2 ? 1 : -1)\n}\n\nexport function ImageFan({\n  images,\n  direction = \"right\",\n  speed = 1,\n  blur = 10,\n  rise = 1.2,\n  overlap = 0.72,\n  rotate = 3,\n  arc = 6,\n  stagger = 0.06,\n  cardWidth = \"min(20cqw, 220px)\",\n  aspectRatio = \"3 / 4\",\n  radius = 14,\n  hoverLift = true,\n  once = true,\n  scroller,\n  height = \"28rem\",\n  className,\n}: ImageFanProps) {\n  const rootRef = useRef<HTMLDivElement>(null)\n  const reduced = useReducedMotion()\n  const n = images.length\n\n  // Hide images that failed before hydration attached onError\n  useEffect(() => {\n    rootRef.current?.querySelectorAll(\"img\").forEach((img) => {\n      if (img.complete && img.naturalWidth === 0) img.style.visibility = \"hidden\"\n    })\n  }, [images])\n\n  useGSAP(\n    () => {\n      const root = rootRef.current\n      if (!root || !n) return\n      const cards = gsap.utils.toArray<HTMLElement>(\"[data-fan-card]\", root)\n      const s = Math.max(speed, 0.05)\n\n      // Spacing shrinks automatically so the whole fan fits the container\n      const step = () => {\n        const w = cards[0].offsetWidth\n        const maxSlot = Math.max(...cards.map((_, i) => Math.abs(slotOf(i, n, direction))), 1)\n        const room = (root.clientWidth - w - 32) / 2 / maxSlot\n        return Math.max(Math.min(w * overlap, room), w * 0.12)\n      }\n      const slot = (i: number) => slotOf(i, n, direction)\n      const startX = () => slot(0) * step()\n      const final = (i: number) => ({\n        x: () => slot(i) * step(),\n        y: () => slot(i) ** 2 * arc,\n        rotation: (slot(i) - slot(0)) * rotate,\n      })\n\n      if (reduced) {\n        cards.forEach((c, i) => gsap.set(c, { xPercent: -50, yPercent: -50, ...final(i), autoAlpha: 1 }))\n        return\n      }\n\n      // Everything starts stacked where the first card lands; only the first is visible\n      cards.forEach((c, i) =>\n        gsap.set(c, { xPercent: -50, yPercent: -50, x: startX, y: 0, rotation: 0, autoAlpha: i === 0 ? 1 : 0 })\n      )\n\n      const tl = gsap.timeline({ paused: true })\n\n      // 1. First card rises from below\n      tl.fromTo(\n        cards[0],\n        { y: () => cards[0].offsetHeight * rise, autoAlpha: 0, filter: blur ? `blur(${blur}px)` : \"none\" },\n        { y: 0, autoAlpha: 1, filter: \"blur(0px)\", duration: 0.9 / s, ease: \"power3.out\" }\n      )\n\n      // 2. The rest slide out from under it into the fan\n      tl.set(cards.slice(1), { autoAlpha: 1 })\n      tl.to(cards[0], { ...final(0), duration: 1.1 / s, ease: \"power3.inOut\" }, \">-0.05\")\n      cards.slice(1).forEach((c, k) => {\n        tl.fromTo(\n          c,\n          { filter: blur ? `blur(${blur / 2}px)` : \"none\" },\n          { ...final(k + 1), filter: \"blur(0px)\", duration: 1.1 / s, ease: \"power3.inOut\" },\n          `<${k === 0 ? 0 : stagger / s}`\n        )\n      })\n\n      ScrollTrigger.create({\n        trigger: root,\n        scroller,\n        start: \"top 75%\",\n        onEnter: () => tl.play(),\n        onLeaveBack: () => !once && tl.reverse(),\n        invalidateOnRefresh: true,\n        onRefresh: () => tl.invalidate(),\n      })\n    },\n    {\n      scope: rootRef,\n      dependencies: [images, direction, speed, blur, rise, overlap, rotate, arc, stagger, once, scroller, reduced],\n      revertOnUpdate: true,\n    }\n  )\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\"relative w-full overflow-hidden [container-type:inline-size]\", className)}\n      style={{ height }}\n    >\n      {images.map((img, i) => (\n        <div\n          key={i}\n          data-fan-card\n          className=\"invisible absolute left-1/2 top-1/2\"\n          // The first card sits on top; the rest tuck underneath in order\n          style={{ width: cardWidth, zIndex: n - Math.abs(slotOf(i, n, direction) - slotOf(0, n, direction)) }}\n        >\n          <div\n            className={cn(\n              \"overflow-hidden shadow-[0_18px_40px_-12px_rgb(0_0_0/0.45)]\",\n              hoverLift && \"transition-transform duration-300 ease-out hover:-translate-y-3 hover:scale-[1.04]\"\n            )}\n            style={{ aspectRatio, borderRadius: radius, background: tint(i) }}\n          >\n            <img src={img.src} alt={img.alt} onError={hideBroken} draggable={false} className=\"size-full object-cover\" />\n          </div>\n        </div>\n      ))}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/image-fan",
  "type": "registry:component"
}