Image Trail
A cursor-following image trail effect with GSAP-powered animations.
Overview
An interactive component that spawns images along the cursor path as you move. Images appear with spring animations and fade out automatically, creating a visual echo effect.
Features
- Cursor-tracking image spawning
- Distance-based spawn threshold
- Automatic fade-out with stagger
- Random rotation for organic feel
- Customizable image pool
Preview
Visual Echo
Trace the movement
Installation
Method 1: CLI (Recommended)
Install via the ZenBlocks CLI to automatically handle dependencies:
npx shadcn@latest add https://zenblocks-three.vercel.app/r/image-trail.jsonMethod 2: Manual
-
Install Dependencies
npm install gsap @gsap/react -
Copy the Source Code
Copy the code below into
components/zenblocks/image-trail.tsx.Click to expand source
"use client"; import React, { useRef, useEffect, useState } from "react"; import { useGSAP } from "@gsap/react"; import gsap from "gsap"; import { cn } from "@/lib/utils"; export const DEFAULT_IMAGES = [ "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?q=80&w=2000&auto=format&fit=crop", "https://images.unsplash.com/photo-1501854140801-50d01698950b?q=80&w=2000&auto=format&fit=crop", "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?q=80&w=2000&auto=format&fit=crop", "https://images.unsplash.com/photo-1506744038136-46273834b3fb?q=80&w=2000&auto=format&fit=crop", "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?q=80&w=2000&auto=format&fit=crop", "https://images.unsplash.com/photo-1502082553048-f009c37129b9?q=80&w=2000&auto=format&fit=crop", "https://images.unsplash.com/photo-1433086966358-54859d0ed716?q=80&w=2000&auto=format&fit=crop", ]; interface ImageTrailProps { images?: string[]; className?: string; containerClassName?: string; } export const ImageTrail = ({ images = DEFAULT_IMAGES, className, containerClassName, }: ImageTrailProps) => { const containerRef = useRef<HTMLDivElement>(null); const imagesRef = useRef<HTMLDivElement[]>([]); const lastX = useRef(0); const lastY = useRef(0); const zIndex = useRef(1); const activeIndex = useRef(0); // Initial setup: hide all images useGSAP(() => { gsap.set(imagesRef.current, { opacity: 0, scale: 0.5, xPercent: -50, yPercent: -50, visibility: "hidden" }); }, { scope: containerRef }); const spawnImage = (x: number, y: number) => { const dist = Math.hypot(x - lastX.current, y - lastY.current); if (dist > 60) { const img = imagesRef.current[activeIndex.current]; if (img) { zIndex.current += 1; // Reset and clear any existing animation on this specific element gsap.killTweensOf(img); // Animate from cursor position gsap.fromTo(img, { x: x, y: y, scale: 0.5, opacity: 0, visibility: "visible", rotate: Math.random() * 30 - 15, zIndex: zIndex.current }, { scale: 1, opacity: 1, duration: 0.4, ease: "back.out(2)", onComplete: () => { // Fade out after a short delay gsap.to(img, { opacity: 0, scale: 0.8, delay: 1, duration: 0.8, ease: "power2.inOut", onComplete: () => { gsap.set(img, { visibility: "hidden" }); } }); } } ); // Update trackers activeIndex.current = (activeIndex.current + 1) % images.length; lastX.current = x; lastY.current = y; } } }; const handleMouseMove = (e: React.MouseEvent) => { if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; spawnImage(x, y); }; const handleTouchMove = (e: React.TouchEvent) => { if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const touch = e.touches[0]; const x = touch.clientX - rect.left; const y = touch.clientY - rect.top; spawnImage(x, y); }; return ( <div ref={containerRef} onMouseMove={handleMouseMove} onTouchMove={handleTouchMove} className={cn( "relative w-full h-[400px] md:h-[600px] overflow-hidden bg-zinc-50 dark:bg-zinc-950 rounded-[3rem] border border-zinc-200 dark:border-zinc-800 cursor-crosshair group touch-none", containerClassName )} > {/* Background Decor */} <div className="absolute inset-0 opacity-[0.05] pointer-events-none" style={{ backgroundImage: 'radial-gradient(circle, currentColor 1px, transparent 1px)', backgroundSize: '30px 30px' }} /> <div className="absolute inset-0 flex flex-col items-center justify-center text-center z-10 pointer-events-none select-none"> <h2 className="text-3xl sm:text-4xl md:text-6xl font-black uppercase tracking-tighter text-zinc-900 dark:text-white"> Visual <span className="text-blue-500">Echo</span> </h2> <p className="mt-4 text-[10px] sm:text-xs font-black uppercase tracking-[0.4em] text-zinc-500 opacity-60"> Trace the movement </p> </div> {/* Trail Elements */} {images.map((src, i) => ( <div key={i} ref={(el) => { if (el) imagesRef.current[i] = el; }} className={cn( "absolute pointer-events-auto rounded-3xl overflow-hidden shadow-2xl border-2 border-white dark:border-zinc-800 transition-transform duration-300 hover:scale-110", className )} style={{ width: '200px', height: '260px' }} > <img src={src} alt="echo" className="w-full h-full object-cover" loading="lazy" /> </div> ))} </div> ); };
Usage
import { ImageTrail } from "@/components/zenblocks/image-trail";
<ImageTrail />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| images | string[] | DEFAULT_IMAGES | Array of image URLs |
| className | string | - | Classes for individual images |
| containerClassName | string | - | Classes for container |
Accessibility
- Decorative images use empty
altattributes - Does not interfere with keyboard navigation
- Respects
prefers-reduced-motion(disables spawning) - Container uses
cursor: crosshairfor visual feedback
Customization
Custom Images
const myImages = [
"/image1.jpg",
"/image2.jpg",
"/image3.jpg"
];
<ImageTrail images={myImages} />Image Styling
<ImageTrail className="rounded-lg shadow-2xl" />Motion Behavior
- Spawn trigger: Images appear when cursor moves >60px from last spawn point
- Entrance: Scale from 0.5 to 1.0 with
back.out(2)easing - Exit: Fade to 0 opacity and scale to 0.8 after 1 second delay
- Rotation: Random ±15deg rotation on spawn
Performance Notes
- Uses GSAP's
killTweensOfto prevent animation conflicts - Images cycle through array index (modulo operation)
- Maximum visible images limited by spawn distance threshold
- DOM elements reused via array indexing
Examples
Hero Background
<div className="relative min-h-screen">
<ImageTrail />
<div className="relative z-10">
<h1>Your Content</h1>
</div>
</div>Notes
- Default spawn distance is 60px (tune for denser/sparser trails)
- Images are positioned absolutely within container
- Works best with square or portrait-oriented images
- Requires GSAP library