{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-trail",
  "type": "registry:ui",
  "title": "Image Trail",
  "description": "Spawns a trail of images that follow the cursor across a scoped area, with multiple appearance styles.",
  "files": [
    {
      "path": "registry/sonaui/image-trail/image-trail.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport {\n  AnimatePresence,\n  motion,\n  type TargetAndTransition,\n  useReducedMotion,\n} from \"motion/react\";\nimport { type ReactNode, useEffect, useRef, useState } from \"react\";\n\nimport { motionTransition } from \"@/lib/sona-motion\";\nimport { cn } from \"@/lib/sona-utils\";\n\ntype ImageTrailVariant = \"scale\" | \"fade\" | \"blur\" | \"rise\" | \"tilt\";\n\nexport interface ImageTrailProps {\n  /** Image sources spawned in order as the pointer moves. */\n  images: string[];\n  /** The content the effect is scoped to; the trail only spawns over this area. */\n  children: ReactNode;\n  /** Additional CSS classes for the container. */\n  className?: string;\n  /** Extra classes for each trail image (use for sizing, e.g. `\"w-48\"`). @default \"w-40\" */\n  itemClassName?: string;\n  /**\n   * Pointer distance in pixels travelled before the next image spawns.\n   * @default 80\n   */\n  threshold?: number;\n  /**\n   * Maximum number of images alive at once.\n   * @default 8\n   */\n  maxImages?: number;\n  /**\n   * How long an image stays before it animates out, in milliseconds.\n   * @default 600\n   */\n  lifetime?: number;\n  /**\n   * Entrance and exit animation style.\n   * @default \"scale\"\n   */\n  variant?: \"scale\" | \"fade\" | \"blur\" | \"rise\" | \"tilt\";\n  /**\n   * CSS selector for elements that suppress the effect when hovered, keeping\n   * interactive UI unobstructed.\n   * @default \"a, button, input, [data-image-trail-ignore]\"\n   */\n  ignoreSelector?: string;\n  /**\n   * z-index of the trail layer.\n   * @default 10\n   */\n  zIndex?: number;\n}\n\ninterface TrailItem {\n  id: number;\n  src: string;\n  x: number;\n  y: number;\n  rotate: number;\n}\n\nconst VARIANTS: Record<\n  ImageTrailVariant,\n  {\n    initial: TargetAndTransition;\n    animate: TargetAndTransition;\n    exit: TargetAndTransition;\n  }\n> = {\n  scale: {\n    initial: { opacity: 0, scale: 0.4 },\n    animate: { opacity: 1, scale: 1 },\n    exit: { opacity: 0, scale: 0.8 },\n  },\n  fade: {\n    initial: { opacity: 0 },\n    animate: { opacity: 1 },\n    exit: { opacity: 0 },\n  },\n  blur: {\n    initial: { opacity: 0, filter: \"blur(12px)\" },\n    animate: { opacity: 1, filter: \"blur(0px)\" },\n    exit: { opacity: 0, filter: \"blur(8px)\" },\n  },\n  rise: {\n    initial: { opacity: 0, y: 24 },\n    animate: { opacity: 1, y: 0 },\n    exit: { opacity: 0, y: -24 },\n  },\n  tilt: {\n    initial: { opacity: 0, scale: 0.6 },\n    animate: { opacity: 1, scale: 1 },\n    exit: { opacity: 0, scale: 0.8 },\n  },\n};\n\nexport default function ImageTrail({\n  images,\n  children,\n  className,\n  itemClassName = \"w-40\",\n  threshold = 80,\n  maxImages = 8,\n  lifetime = 600,\n  variant = \"scale\",\n  ignoreSelector = \"a, button, input, [data-image-trail-ignore]\",\n  zIndex = 10,\n}: ImageTrailProps) {\n  const prefersReducedMotion = useReducedMotion();\n  const [trail, setTrail] = useState<TrailItem[]>([]);\n\n  const lastPos = useRef<{ x: number; y: number } | null>(null);\n  const containerRect = useRef<DOMRect | null>(null);\n  const imageIndex = useRef(0);\n  const idCounter = useRef(0);\n  const timers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());\n  const preloadedImages = useRef(new Set<string>());\n\n  // Clear any pending removals when the component unmounts.\n  useEffect(() => {\n    const pending = timers.current;\n    return () => {\n      pending.forEach(clearTimeout);\n      pending.clear();\n    };\n  }, []);\n\n  // Decode the images before the first interaction so pointer movement does\n  // not compete with image loading and decoding.\n  useEffect(() => {\n    images.forEach((src) => {\n      if (preloadedImages.current.has(src)) return;\n      preloadedImages.current.add(src);\n      const image = new Image();\n      image.src = src;\n    });\n  }, [images]);\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (prefersReducedMotion || event.pointerType !== \"mouse\") return;\n\n    // Over interactive UI: stop spawning and fast-clear live images so they\n    // don't obstruct it. Their exit animation still plays via AnimatePresence.\n    if ((event.target as Element).closest(ignoreSelector)) {\n      timers.current.forEach(clearTimeout);\n      timers.current.clear();\n      lastPos.current = null;\n      setTrail((prev) => (prev.length ? [] : prev));\n      return;\n    }\n\n    const rect =\n      containerRect.current ?? event.currentTarget.getBoundingClientRect();\n    containerRect.current = rect;\n    const x = event.clientX - rect.left;\n    const y = event.clientY - rect.top;\n\n    const last = lastPos.current;\n    if (last && Math.hypot(x - last.x, y - last.y) < threshold) return;\n    lastPos.current = { x, y };\n\n    const src = images[imageIndex.current % images.length];\n    imageIndex.current += 1;\n    const id = idCounter.current++;\n    const rotate = (Math.random() - 0.5) * (variant === \"tilt\" ? 24 : 8);\n\n    setTrail((prev) => {\n      const next = [...prev, { id, src, x, y, rotate }];\n      return next.length > maxImages\n        ? next.slice(next.length - maxImages)\n        : next;\n    });\n\n    const timer = setTimeout(() => {\n      setTrail((prev) => prev.filter((item) => item.id !== id));\n      timers.current.delete(timer);\n    }, lifetime);\n    timers.current.add(timer);\n  };\n\n  const active = VARIANTS[variant];\n\n  return (\n    <div\n      onPointerMove={handlePointerMove}\n      onPointerEnter={(event) => {\n        containerRect.current = event.currentTarget.getBoundingClientRect();\n      }}\n      onPointerLeave={() => {\n        lastPos.current = null;\n        containerRect.current = null;\n      }}\n      className={cn(\"relative overflow-hidden\", className)}\n    >\n      <div\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0\"\n        style={{ zIndex }}\n      >\n        <AnimatePresence>\n          {trail.map((item) => (\n            // biome-ignore lint/performance/noImgElement: registry components stay framework-agnostic — no next/image dependency\n            <motion.img\n              key={item.id}\n              src={item.src}\n              alt=\"\"\n              draggable={false}\n              className={cn(\n                \"absolute -translate-x-1/2 -translate-y-1/2 rounded-lg object-cover shadow-lg\",\n                \"will-change-transform\",\n                itemClassName,\n              )}\n              style={{ left: item.x, top: item.y, rotate: item.rotate }}\n              initial={active.initial}\n              animate={active.animate}\n              exit={active.exit}\n              transition={motionTransition.enter}\n            />\n          ))}\n        </AnimatePresence>\n      </div>\n      {children}\n    </div>\n  );\n}\n",
      "target": "components/ui/image-trail/image-trail.tsx"
    }
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "@sona-ui/sona-utils",
    "@sona-ui/sona-motion"
  ]
}