{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A CursorImageTrail component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useRef, useState } from \"react\";\n\nexport interface CursorTrailImage {\n  alt: string;\n  src: string;\n}\n\nexport interface CursorImageTrailProps {\n  children: ReactNode;\n  className?: string;\n  distance?: number;\n  imageSize?: number;\n  images: CursorTrailImage[];\n  lifetime?: number;\n  poolSize?: number;\n  rotate?: number;\n  velocityScale?: number;\n}\n\ninterface TrailSlot {\n  active: boolean;\n  duration: number;\n  rotation: number;\n  scale: number;\n  startTime: number;\n  x: number;\n  y: number;\n}\n\nconst DEFAULT_POOL_SIZE = 12;\nconst DEFAULT_DISTANCE = 90;\nconst DEFAULT_LIFETIME = 700;\nconst DEFAULT_ROTATE = 12;\nconst DEFAULT_VELOCITY_SCALE = 0.5;\nconst DEFAULT_IMAGE_SIZE = 96;\nconst ENTER_RATIO = 0.25;\nconst EXIT_RATIO = 0.35;\nconst MAX_FRAME_DELTA_MS = 50;\n\nconst easeOutQuint = (t: number) => 1 - (1 - t) ** 5;\nconst easeInOutCubic = (t: number) =>\n  t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;\n\nconst clamp = (value: number, min: number, max: number) =>\n  Math.min(Math.max(value, min), max);\n\nconst createSlots = (poolSize: number, lifetime: number): TrailSlot[] =>\n  Array.from({ length: poolSize }, () => ({\n    active: false,\n    duration: lifetime,\n    rotation: 0,\n    scale: 1,\n    startTime: 0,\n    x: 0,\n    y: 0,\n  }));\n\nconst CursorImageTrail = ({\n  children,\n  className,\n  distance = DEFAULT_DISTANCE,\n  imageSize = DEFAULT_IMAGE_SIZE,\n  images,\n  lifetime = DEFAULT_LIFETIME,\n  poolSize = DEFAULT_POOL_SIZE,\n  rotate = DEFAULT_ROTATE,\n  velocityScale = DEFAULT_VELOCITY_SCALE,\n}: CursorImageTrailProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const [isHoverDevice, setIsHoverDevice] = useState(false);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const slotRefs = useRef<(HTMLImageElement | null)[]>([]);\n  const slotStateRef = useRef<TrailSlot[]>(createSlots(poolSize, lifetime));\n  const pointerRef = useRef<{ x: number; y: number } | null>(null);\n  const isInsideRef = useRef(false);\n  const lastFrameRef = useRef<{ t: number; x: number; y: number } | null>(null);\n  const lastSpawnRef = useRef<{ x: number; y: number } | null>(null);\n  const nextSlotRef = useRef(0);\n  const nextImageRef = useRef(0);\n  const rafRef = useRef<number | null>(null);\n\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    setIsHoverDevice(mediaQuery.matches);\n\n    const handleChange = (event: MediaQueryListEvent) => {\n      setIsHoverDevice(event.matches);\n    };\n\n    mediaQuery.addEventListener(\"change\", handleChange);\n    return () => mediaQuery.removeEventListener(\"change\", handleChange);\n  }, []);\n\n  const effectEnabled =\n    isHoverDevice && !shouldReduceMotion && images.length > 0;\n\n  useEffect(() => {\n    if (!effectEnabled) {\n      return;\n    }\n\n    const container = containerRef.current;\n    if (!container) {\n      return;\n    }\n\n    slotStateRef.current = createSlots(poolSize, lifetime);\n    nextSlotRef.current = 0;\n    nextImageRef.current = 0;\n    lastFrameRef.current = null;\n    lastSpawnRef.current = null;\n\n    const spawn = (x: number, y: number, velocity: number) => {\n      const slotIndex = nextSlotRef.current % poolSize;\n      nextSlotRef.current += 1;\n      const imageIndex = nextImageRef.current % images.length;\n      nextImageRef.current += 1;\n\n      const image = images[imageIndex];\n      const el = slotRefs.current[slotIndex];\n      if (!(el && image)) {\n        return;\n      }\n\n      el.src = image.src;\n      el.alt = image.alt;\n\n      const speedFactor = clamp(velocity * velocityScale, 0, 1);\n      const rotation = (Math.random() * 2 - 1) * rotate;\n\n      slotStateRef.current[slotIndex] = {\n        active: true,\n        duration: lifetime * (1 + speedFactor * 0.6),\n        rotation,\n        scale: 1 + speedFactor * 0.5,\n        startTime: performance.now(),\n        x,\n        y,\n      };\n    };\n\n    const tick = (now: number) => {\n      if (pointerRef.current && isInsideRef.current) {\n        const rect = container.getBoundingClientRect();\n        const x = pointerRef.current.x - rect.left;\n        const y = pointerRef.current.y - rect.top;\n\n        let velocity = 0;\n        if (lastFrameRef.current) {\n          const dt = Math.min(now - lastFrameRef.current.t, MAX_FRAME_DELTA_MS);\n          if (dt > 0) {\n            const dx = x - lastFrameRef.current.x;\n            const dy = y - lastFrameRef.current.y;\n            velocity = Math.hypot(dx, dy) / dt;\n          }\n        }\n        lastFrameRef.current = { t: now, x, y };\n\n        if (lastSpawnRef.current) {\n          const dx = x - lastSpawnRef.current.x;\n          const dy = y - lastSpawnRef.current.y;\n          if (Math.hypot(dx, dy) >= distance) {\n            spawn(x, y, velocity);\n            lastSpawnRef.current = { x, y };\n          }\n        } else {\n          lastSpawnRef.current = { x, y };\n        }\n      }\n\n      for (let i = 0; i < slotStateRef.current.length; i++) {\n        const slot = slotStateRef.current[i];\n        const el = slotRefs.current[i];\n        if (!(slot?.active && el)) {\n          continue;\n        }\n\n        const elapsed = now - slot.startTime;\n        const progress = clamp(elapsed / slot.duration, 0, 1);\n\n        let opacity: number;\n        let scale: number;\n\n        if (progress <= ENTER_RATIO) {\n          const enterProgress = progress / ENTER_RATIO;\n          const eased = easeOutQuint(enterProgress);\n          opacity = eased;\n          scale = eased * slot.scale;\n        } else if (progress >= 1 - EXIT_RATIO) {\n          const exitProgress = (progress - (1 - EXIT_RATIO)) / EXIT_RATIO;\n          const eased = easeInOutCubic(exitProgress);\n          opacity = 1 - eased;\n          scale = slot.scale * (1 - eased * 0.15);\n        } else {\n          opacity = 1;\n          ({ scale } = slot);\n        }\n\n        el.style.transform = `translate3d(${slot.x}px, ${slot.y}px, 0) translate(-50%, -50%) rotate(${slot.rotation}deg) scale(${scale})`;\n        el.style.opacity = String(opacity);\n\n        if (progress >= 1) {\n          slot.active = false;\n          el.style.opacity = \"0\";\n        }\n      }\n\n      rafRef.current = requestAnimationFrame(tick);\n    };\n\n    const handlePointerMove = (event: PointerEvent) => {\n      pointerRef.current = { x: event.clientX, y: event.clientY };\n      isInsideRef.current = true;\n    };\n    const handlePointerEnter = (event: PointerEvent) => {\n      pointerRef.current = { x: event.clientX, y: event.clientY };\n      isInsideRef.current = true;\n      lastSpawnRef.current = null;\n      lastFrameRef.current = null;\n    };\n    const handlePointerLeave = () => {\n      isInsideRef.current = false;\n    };\n\n    container.addEventListener(\"pointermove\", handlePointerMove);\n    container.addEventListener(\"pointerenter\", handlePointerEnter);\n    container.addEventListener(\"pointerleave\", handlePointerLeave);\n    rafRef.current = requestAnimationFrame(tick);\n\n    return () => {\n      container.removeEventListener(\"pointermove\", handlePointerMove);\n      container.removeEventListener(\"pointerenter\", handlePointerEnter);\n      container.removeEventListener(\"pointerleave\", handlePointerLeave);\n      if (rafRef.current !== null) {\n        cancelAnimationFrame(rafRef.current);\n        rafRef.current = null;\n      }\n    };\n  }, [\n    effectEnabled,\n    images,\n    poolSize,\n    lifetime,\n    distance,\n    rotate,\n    velocityScale,\n  ]);\n\n  return (\n    <div\n      className={cn(\"relative overflow-hidden\", className)}\n      ref={containerRef}\n    >\n      {children}\n      {effectEnabled ? (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 select-none\"\n        >\n          {Array.from({ length: poolSize }).map((_, index) => (\n            <img\n              alt=\"\"\n              className=\"absolute top-0 left-0 rounded-xl object-cover opacity-0 shadow-lg will-change-transform\"\n              draggable={false}\n              // biome-ignore lint/suspicious/noArrayIndexKey: pool slots are positional and never reorder\n              key={index}\n              ref={(el) => {\n                slotRefs.current[index] = el;\n              }}\n              src={images[0]?.src}\n              style={{ height: imageSize, width: imageSize }}\n            />\n          ))}\n        </div>\n      ) : null}\n    </div>\n  );\n};\n\nexport default CursorImageTrail;\n","path":"index.tsx","target":"components/smoothui/cursor-image-trail/index.tsx","type":"registry:ui"}],"name":"cursor-image-trail","registryDependencies":[],"title":"Cursor Image Trail","type":"registry:ui"}