Image Effects
ImageTrail
Moving the pointer or a finger drops pictures along the way. Each pops up, holds, and fades.
Download
- React
- Tailwind CSS v4
- motion
components/ui/image-trail.tsx
"use client";
import { useRef, type ReactNode } from "react";
import { useReducedMotion } from "motion/react";
type ImageTrailProps = {
/** Picture addresses. They are decoration, so keep nothing important only here. */
images: string[];
/** The area the trail shows over. */
children?: ReactNode;
/** Pixels the pointer moves before the next picture appears. */
gap?: number;
/** Width of each picture in pixels. */
size?: number;
/** Seconds a picture lives. */
life?: number;
/** Set the size here. */
className?: string;
};
const SLOTS = 8;
/* ImageTrail: moving the pointer or a finger drops pictures along the way. Each pops up, holds, and fades. */
export function ImageTrail({ images, children, gap = 90, size = 150, life = 1, className = "h-80" }: ImageTrailProps) {
const reduce = useReducedMotion();
const slots = useRef<(HTMLImageElement | null)[]>([]);
const last = useRef<{ x: number; y: number } | null>(null);
const turn = useRef(0);
const pile = useRef(0);
const pool = Array.from({ length: Math.max(images.length, SLOTS) }, (_, i) => images[i % images.length]);
const drop = (e: React.PointerEvent<HTMLDivElement>) => {
const img = slots.current[turn.current];
if (reduce || !img) return;
const r = e.currentTarget.getBoundingClientRect();
const x = e.clientX - r.left;
const y = e.clientY - r.top;
if (last.current && Math.hypot(x - last.current.x, y - last.current.y) < gap) return;
last.current = { x, y };
turn.current = (turn.current + 1) % pool.length;
pile.current += 1;
const tilt = `translate(-50%, -50%) rotate(${(Math.random() - 0.5) * 16}deg)`;
img.style.left = `${x}px`;
img.style.top = `${y}px`;
img.style.zIndex = String(pile.current);
img.animate(
[
{ opacity: 0, transform: `${tilt} scale(0.5)` },
{ opacity: 1, transform: `${tilt} scale(1)`, offset: 0.15 },
{ opacity: 1, transform: `${tilt} scale(1)`, offset: 0.6 },
{ opacity: 0, transform: `${tilt} scale(0.85)` },
],
{ duration: life * 1000, easing: "cubic-bezier(0.2, 0.8, 0.2, 1)" },
);
};
return (
<div
onPointerDown={drop}
onPointerMove={drop}
onPointerLeave={() => (last.current = null)}
className={`relative isolate touch-pan-y select-none overflow-hidden ${className}`}
>
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
{pool.map((src, i) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={i}
ref={(el) => {
slots.current[i] = el;
}}
src={src}
alt=""
draggable={false}
className="absolute aspect-[4/5] rounded-xl object-cover opacity-0 shadow-xl shadow-black/40"
style={{ width: size }}
/>
))}
</div>
{children}
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/image-trail.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/image-trail-demo.tsx
import { ImageTrail } from "@/components/ui/image-trail"; const IMAGES = ["/demo/dawn.jpg", "/demo/ember.jpg", "/demo/moss.jpg", "/demo/tide.jpg", "/demo/dusk.jpg"]; export default function ImageTrailExample() { return ( <ImageTrail images={IMAGES} size={120} className="grid h-full place-items-center bg-[#0b0a14] p-6 text-center"> <div> <p className="text-2xl font-semibold text-white">Move your pointer here</p> <p className="mt-1 text-sm text-white/55">Pictures drop along the way and fade.</p> </div> </ImageTrail> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| images | string[] | — | Picture addresses. They are decoration, so keep nothing important only here. |
| children | ReactNode | — | The area the trail shows over. |
| gap | number | 90 | Pixels the pointer moves before the next picture appears. |
| size | number | 150 | Width of each picture in pixels. |
| life | number | 1 | Seconds a picture lives. |
| className | string | "h-80" | Set the size here. |