{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"mouse-trail","type":"registry:component","title":"Mouse trail","description":"An image trail that follows the cursor.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/mouse-trail/mouse-trail.tsx","type":"registry:component","target":"components/dimaac/mouse-trail.tsx","content":"\"use client\";\n\nimport { useRef } from \"react\";\nimport { gsap } from \"gsap\";\nimport { useGSAP } from \"@gsap/react\";\n\n// A stack of photos that chases the cursor. Every card follows the same path a little later than\n// the one above it, so the stack stretches into a trail when you move and gathers when you stop.\n// On touch screens (or always, if you ask) it drifts along a slow loop by itself.\n\nexport type MouseTrailProps = {\n  /** The photos, top card first. Six to twelve look best. */\n  images: string[];\n  /** Width of each card, in pixels. */\n  imageWidth?: number;\n  /** Height of each card, in pixels. */\n  imageHeight?: number;\n  /** How far behind the card above it each card runs, in seconds. */\n  stagger?: number;\n  /** How long a card takes to catch up with its spot, in seconds. */\n  duration?: number;\n  /** The GSAP ease of that catch-up, e.g. \"power2.out\" or \"expo.out\". */\n  ease?: string;\n  /** When the stack drifts by itself: on touch screens only, always (whenever no pointer is over it) or never. */\n  autoplay?: \"touch\" | \"always\" | \"never\";\n  /** Extra classes for the outer box. Give it a size (it fills it). */\n  className?: string;\n  /** Extra classes for each card, e.g. a radius or a shadow. */\n  imageClassName?: string;\n};\n\ntype Point = { t: number; x: number; y: number };\n\nexport function MouseTrail({\n  images,\n  imageWidth = 170,\n  imageHeight = 238,\n  stagger = 0.04,\n  duration = 0.5,\n  ease = \"power3.out\",\n  autoplay = \"touch\",\n  className = \"\",\n  imageClassName = \"rounded-[4px] shadow-[0_16px_34px_-14px_rgba(0,0,0,0.3)]\",\n}: MouseTrailProps) {\n  const boxRef = useRef<HTMLDivElement>(null);\n  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);\n  // Read on every frame, so a new stagger doesn't rebuild anything\n  const staggerRef = useRef(stagger);\n  staggerRef.current = stagger;\n\n  useGSAP(\n    (_context, contextSafe) => {\n      const box = boxRef.current;\n      const cards = cardRefs.current.slice(0, images.length).filter((card): card is HTMLDivElement => card !== null);\n      if (!box || cards.length === 0 || !contextSafe) return;\n\n      gsap.set(cards, { xPercent: -50, yPercent: -50, x: box.clientWidth / 2, y: box.clientHeight / 2, force3D: true });\n\n      // Reduced motion: no chase. The stack sits in the middle, fanned out a little, and stays put.\n      const mm = gsap.matchMedia();\n      mm.add(\"(prefers-reduced-motion: reduce)\", () => {\n        const middle = (cards.length - 1) / 2;\n        gsap.set(cards.map((card) => card.firstElementChild), { opacity: 1 });\n        gsap.set(cards, { opacity: 1, left: \"50%\", top: \"50%\", x: 0, y: 0, rotation: (i: number) => (i - middle) * 4, xPercent: (i: number) => -50 + (i - middle) * 6 });\n      });\n\n      const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n      if (reduced) return () => mm.revert();\n\n      const touchScreen = window.matchMedia(\"(hover: none)\").matches;\n      const drifts = autoplay === \"always\" || (autoplay === \"touch\" && touchScreen);\n\n      // One quickTo per card and axis: cheap to call every frame, eased toward the newest value\n      const moveX = cards.map((card) => gsap.quickTo(card, \"x\", { duration, ease }));\n      const moveY = cards.map((card) => gsap.quickTo(card, \"y\", { duration, ease }));\n      // A card that sits right under the one above it is hidden, so ten shadows don't pile up\n      // into a dark halo when the stack gathers. It fades in as it pulls away.\n      const fade = cards.map((card) => gsap.quickSetter(card.firstElementChild, \"opacity\"));\n\n      // Where the trail's head has been, so each card can be sent where the head was a moment ago\n      const history: Point[] = [];\n      const target = { x: box.clientWidth / 2, y: box.clientHeight / 2 };\n      let pointerInside = false;\n      let lastInput = -Infinity;\n      let shown = false;\n      let visible = true;\n      let clock = 0;\n\n      const show = (on: boolean) => {\n        if (on === shown) return;\n        shown = on;\n        gsap.to(cards, { opacity: on ? 1 : 0, duration: on ? 0.25 : 0.4, ease: \"power2.out\", stagger: on ? staggerRef.current / 2 : 0, overwrite: \"auto\" });\n      };\n\n      const pointAt = (time: number): Point => {\n        for (let i = history.length - 1; i >= 0; i--) if (history[i]!.t <= time) return history[i]!;\n        return history[0] ?? { t: time, x: target.x, y: target.y };\n      };\n\n      const tick = (seconds: number) => {\n        if (!visible) return;\n        clock = seconds;\n        const idle = seconds - lastInput > 1.2 && !pointerInside;\n        if (drifts && idle) {\n          // A slow figure of eight around the middle of the box\n          const w = box.clientWidth;\n          const h = box.clientHeight;\n          target.x = w / 2 + Math.sin(seconds * 0.8) * w * 0.28;\n          target.y = h / 2 + Math.sin(seconds * 1.6) * h * 0.2;\n          show(true);\n        }\n        history.push({ t: seconds, x: target.x, y: target.y });\n        const keep = cards.length * staggerRef.current + 0.5;\n        while (history.length > 2 && history[0]!.t < seconds - keep) history.shift();\n        cards.forEach((_, i) => {\n          const point = pointAt(seconds - i * staggerRef.current);\n          moveX[i]!(point.x);\n          moveY[i]!(point.y);\n          if (i === 0) return;\n          const above = cards[i - 1]!;\n          const gap = Math.hypot(Number(gsap.getProperty(cards[i]!, \"x\")) - Number(gsap.getProperty(above, \"x\")), Number(gsap.getProperty(cards[i]!, \"y\")) - Number(gsap.getProperty(above, \"y\")));\n          fade[i]!(Math.min(1, gap / 20));\n        });\n      };\n      gsap.ticker.add(tick);\n\n      const place = (event: PointerEvent) => {\n        const rect = box.getBoundingClientRect();\n        target.x = event.clientX - rect.left;\n        target.y = event.clientY - rect.top;\n        lastInput = clock;\n      };\n\n      const onPointerMove = contextSafe((event: PointerEvent) => {\n        if (!event.isPrimary) return;\n        place(event);\n        show(true);\n      });\n      const onPointerEnter = contextSafe((event: PointerEvent) => {\n        if (event.pointerType === \"mouse\" || event.pointerType === \"pen\") pointerInside = true;\n      });\n      const onPointerDown = contextSafe((event: PointerEvent) => {\n        if (!event.isPrimary) return;\n        place(event);\n        show(true);\n      });\n      const onPointerLeave = contextSafe(() => {\n        pointerInside = false;\n        if (!drifts) show(false);\n      });\n\n      box.addEventListener(\"pointermove\", onPointerMove);\n      box.addEventListener(\"pointerenter\", onPointerEnter);\n      box.addEventListener(\"pointerdown\", onPointerDown);\n      box.addEventListener(\"pointerleave\", onPointerLeave);\n\n      // No work while the box is off screen\n      const visibility = new IntersectionObserver(([entry]) => {\n        visible = entry?.isIntersecting ?? true;\n      });\n      visibility.observe(box);\n\n      return () => {\n        gsap.ticker.remove(tick);\n        visibility.disconnect();\n        box.removeEventListener(\"pointermove\", onPointerMove);\n        box.removeEventListener(\"pointerenter\", onPointerEnter);\n        box.removeEventListener(\"pointerdown\", onPointerDown);\n        box.removeEventListener(\"pointerleave\", onPointerLeave);\n        mm.revert();\n      };\n    },\n    { scope: boxRef, dependencies: [images.length, duration, ease, autoplay], revertOnUpdate: true },\n  );\n\n  return (\n    <div ref={boxRef} className={`relative h-full w-full overflow-hidden ${className}`} style={{ touchAction: \"pan-y\" }}>\n      {images.map((src, index) => (\n        <div\n          key={`${index}-${src}`}\n          ref={(card) => {\n            cardRefs.current[index] = card;\n          }}\n          className=\"pointer-events-none absolute top-0 left-0 opacity-0\"\n          style={{ width: imageWidth, height: imageHeight, zIndex: images.length - index, willChange: \"transform, opacity\" }}\n        >\n          <div className={`h-full w-full overflow-hidden ${imageClassName}`}>\n            <img src={src} alt=\"\" className=\"block h-full w-full object-cover\" draggable={false} decoding=\"async\" />\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n}\n"}],"categories":["cursor","gsap","images","trail"],"docs":"import { MouseTrail } from \"@/components/dimaac/mouse-trail\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/mouse-trail","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/mouse-trail","credits":[{"by":"Lummi","url":"https://www.lummi.ai","what":"Photos","source":"Lummi","licence":"Lummi License (free for personal and commercial use)"}]}}