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

Live Preview

Visual Echo

Trace the movement

echo
echo
echo
echo
echo
echo
echo

Installation

Install via the ZenBlocks CLI to automatically handle dependencies:

npx shadcn@latest add https://zenblocks-three.vercel.app/r/image-trail.json

Method 2: Manual

  1. Install Dependencies

    npm install gsap @gsap/react
  2. 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

PropTypeDefaultDescription
imagesstring[]DEFAULT_IMAGESArray of image URLs
classNamestring-Classes for individual images
containerClassNamestring-Classes for container

Accessibility

  • Decorative images use empty alt attributes
  • Does not interfere with keyboard navigation
  • Respects prefers-reduced-motion (disables spawning)
  • Container uses cursor: crosshair for 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 killTweensOf to 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