{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-trail",
  "title": "Image Trail",
  "description": "Images spawn along the pointer's path, pop in, and drift away — the signature portfolio hover effect.",
  "registryDependencies": ["@godui/godui-theme"],
  "files": [
    {
      "path": "packages/components/src/image-trail/image-trail.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type ImageTrailProps = React.HTMLAttributes<HTMLDivElement> & {\n  /** Images cycled through as the pointer moves. */\n  images: string[];\n  /** Minimum pointer distance (px) between two spawned images. */\n  threshold?: number;\n  /** Lifespan of each trail image, in ms. */\n  duration?: number;\n  /** Maximum images alive at once (the recycled DOM pool size). */\n  max?: number;\n  /** Width/height of each trail image, in px. */\n  size?: number;\n};\n\nconst ImageTrail = React.forwardRef<HTMLDivElement, ImageTrailProps>(\n  (\n    {\n      images,\n      threshold = 64,\n      duration = 750,\n      max = 12,\n      size = 180,\n      className,\n      children,\n      onPointerMove,\n      ...props\n    },\n    forwardedRef,\n  ) => {\n    const ref = React.useRef<HTMLDivElement>(null);\n    React.useImperativeHandle(\n      forwardedRef,\n      () => ref.current as HTMLDivElement,\n    );\n\n    // Pool of <img> slots, recycled round-robin. We drive everything with the\n    // Web Animations API and direct style writes — no React re-render per move.\n    const slots = React.useRef<(HTMLImageElement | null)[]>([]);\n    const last = React.useRef<{ x: number; y: number } | null>(null);\n    const slotIndex = React.useRef(0);\n    const imageIndex = React.useRef(0);\n\n    const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n      onPointerMove?.(e);\n      const el = ref.current;\n      if (!el || images.length === 0) return;\n\n      const rect = el.getBoundingClientRect();\n      const x = e.clientX - rect.left;\n      const y = e.clientY - rect.top;\n\n      const prev = last.current;\n      if (prev) {\n        const dx = x - prev.x;\n        const dy = y - prev.y;\n        if (Math.hypot(dx, dy) < threshold) return;\n        // Tilt the image a touch toward the direction of travel.\n        spawn(x, y, Math.atan2(dy, dx));\n      } else {\n        spawn(x, y, 0);\n      }\n      last.current = { x, y };\n    };\n\n    const spawn = (x: number, y: number, angleRad: number) => {\n      const img = slots.current[slotIndex.current % max];\n      slotIndex.current += 1;\n      if (!img) return;\n\n      img.src = images[imageIndex.current % images.length] as string;\n      imageIndex.current += 1;\n\n      const tilt = Math.max(-12, Math.min(12, (angleRad * 180) / Math.PI / 6));\n      img.style.left = `${x}px`;\n      img.style.top = `${y}px`;\n\n      img.animate(\n        [\n          {\n            opacity: 0,\n            transform: `translate(-50%, -50%) scale(0.4) rotate(${tilt}deg)`,\n          },\n          {\n            opacity: 1,\n            transform: `translate(-50%, -50%) scale(1) rotate(${tilt}deg)`,\n            offset: 0.18,\n          },\n          {\n            opacity: 0,\n            transform: `translate(-50%, -65%) scale(0.92) rotate(${tilt}deg)`,\n          },\n        ],\n        {\n          duration,\n          easing: \"cubic-bezier(0.22,1,0.36,1)\",\n          fill: \"forwards\",\n        },\n      );\n    };\n\n    return (\n      <div\n        ref={ref}\n        data-slot=\"image-trail\"\n        onPointerMove={handlePointerMove}\n        className={`relative overflow-hidden ${className ?? \"\"}`}\n        {...props}\n      >\n        {/* Reduced-motion users get a calm static gallery instead of a trail. */}\n        <div className=\"pointer-events-none absolute inset-0 hidden place-items-center motion-reduce:grid\">\n          <div className=\"flex flex-wrap justify-center gap-3 p-6 opacity-60\">\n            {images.slice(0, 5).map((src, i) => (\n              <img\n                // biome-ignore lint/suspicious/noArrayIndexKey: fixed gallery order\n                key={i}\n                src={src}\n                alt=\"\"\n                className=\"size-24 rounded-lg object-cover shadow-md\"\n              />\n            ))}\n          </div>\n        </div>\n\n        <div className=\"pointer-events-none absolute inset-0 motion-reduce:hidden\">\n          {Array.from({ length: max }).map((_, i) => (\n            <img\n              // biome-ignore lint/suspicious/noArrayIndexKey: fixed recycled pool slot\n              key={i}\n              ref={(node) => {\n                slots.current[i] = node;\n              }}\n              alt=\"\"\n              aria-hidden\n              className=\"absolute rounded-xl object-cover opacity-0 shadow-2xl ring-1 ring-border/40 will-change-transform\"\n              style={{ width: size, height: size, left: 0, top: 0 }}\n            />\n          ))}\n        </div>\n\n        {children}\n      </div>\n    );\n  },\n);\nImageTrail.displayName = \"ImageTrail\";\n\nexport { ImageTrail };\n",
      "type": "registry:ui",
      "target": "components/godui/image-trail.tsx"
    }
  ],
  "type": "registry:ui"
}
