Skip to content
jjswnth/ui

Motion

Image Cursor Trail

Images spawn behind the cursor as it moves, cycling through the set and clearing themselves out so at most ten are ever alive.

jswnth/ui

Move your cursor

Installation

  1. 1. Install the dependencies.

    terminal
    npm install motion clsx tailwind-merge
  2. 2. Add the cn helper, if you don't already have it.

    lib/utils.ts
    import { clsx, type ClassValue } from "clsx";
    import { twMerge } from "tailwind-merge";
    
    export function cn(...inputs: ClassValue[]) {
      return twMerge(clsx(inputs));
    }
  3. 3. Copy the component into your project.

    components/ui/image-cursor-trail.tsx
    "use client";
    
    import { AnimatePresence, motion } from "motion/react";
    import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
    import { cn } from "@/lib/utils";
    
    type TrailItem = {
      id: number;
      x: number;
      y: number;
      image: string;
      rotate: number;
    };
    
    type ImageCursorTrailProps = {
      /** Cycled through in order as the cursor moves. */
      images: string[];
      /** Most images alive at once. */
      max?: number;
      /** Pixels of cursor travel between spawns. */
      distance?: number;
      /** Milliseconds an image stays before it leaves. */
      life?: number;
      /** Width of each trail image, in pixels. */
      size?: number;
      /** Sits under the trail — a heading, usually. */
      children?: ReactNode;
      className?: string;
    };
    
    export function ImageCursorTrail({
      images,
      max = 10,
      distance = 70,
      life = 700,
      size = 150,
      children,
      className,
    }: ImageCursorTrailProps) {
      const [items, setItems] = useState<TrailItem[]>([]);
    
      const spawned = useRef(0);
      const lastPoint = useRef<{ x: number; y: number } | null>(null);
      const timers = useRef<number[]>([]);
    
      useEffect(() => {
        const pending = timers.current;
        return () => pending.forEach(window.clearTimeout);
      }, []);
    
      const handleMove = useCallback(
        (event: React.MouseEvent<HTMLDivElement>) => {
          const bounds = event.currentTarget.getBoundingClientRect();
          const x = event.clientX - bounds.left;
          const y = event.clientY - bounds.top;
    
          const previous = lastPoint.current;
          if (previous && Math.hypot(x - previous.x, y - previous.y) < distance) return;
          lastPoint.current = { x, y };
    
          const id = spawned.current++;
          const item: TrailItem = {
            id,
            x,
            y,
            image: images[id % images.length]!,
            // Deterministic tilt, so the trail reads as varied without randomness.
            rotate: ((id % 5) - 2) * 7,
          };
    
          // Oldest images fall off the end once the trail is full.
          setItems((current) => [...current, item].slice(-max));
    
          const timer = window.setTimeout(() => {
            setItems((current) => current.filter((entry) => entry.id !== id));
          }, life);
          timers.current.push(timer);
        },
        [distance, images, life, max],
      );
    
      return (
        <div
          onMouseMove={handleMove}
          onMouseLeave={() => {
            lastPoint.current = null;
          }}
          className={cn(
            "relative grid w-full place-items-center overflow-hidden",
            className,
          )}
        >
          <AnimatePresence>
            {items.map((item) => (
              <motion.img
                key={item.id}
                src={item.image}
                alt=""
                aria-hidden
                draggable={false}
                initial={{ opacity: 0, scale: 0.55, rotate: 0 }}
                animate={{ opacity: 1, scale: 1, rotate: item.rotate }}
                exit={{ opacity: 0, scale: 0.72, y: "-64%" }}
                transition={{ duration: 0.32, ease: [0.22, 1, 0.36, 1] }}
                // Centring goes through motion's own x/y, since anything it
                // animates would otherwise overwrite a CSS transform.
                style={{
                  left: item.x,
                  top: item.y,
                  x: "-50%",
                  y: "-50%",
                  width: size,
                  height: size * 1.25,
                  zIndex: item.id,
                }}
                className="pointer-events-none absolute rounded-xl object-cover shadow-[0_18px_40px_-24px_rgb(0_0_0/0.6)]"
              />
            ))}
          </AnimatePresence>
    
          {children ? (
            <div className="pointer-events-none relative z-0 text-center">{children}</div>
          ) : null}
        </div>
      );
    }

Props

PropTypeDefaultDescription
imagesstring[]Cycled through in order as the cursor moves.
maxnumber10Most images alive at once.
distancenumber70Pixels of cursor travel between spawns.
lifenumber700Milliseconds an image stays before it leaves.
sizenumber150Width of each trail image, in pixels.
childrenReactNodeSits under the trail — a heading, usually.