Pointer Trail Gallery
Moving the pointer scatters images along its path: every fixed stretch of travel spawns the next visual with a random tilt, older ones shrink and blur away, and leaving the frame clears the trail. Pointer events mean pen and touch drags leave a trail too, and it stays quiet under reduced motion.
Micro InteractionReactMotionTailwind CSS
CSSTailwind
Manual
Create a file and paste the following code into it.
pointer-trail-gallery.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
"use client";
import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/cn";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export interface PointerTrailGalleryProps {
/** Visuals cycled through the trail (images, cards, any JSX). [Required] */
items: ReactNode[];
/** Rendered width of each trail item in px. [Optional, default: 120] */
itemSize?: number;
/** Max simultaneous items kept on screen. [Optional, default: 8] */
trailLength?: number;
/** Cursor travel in px before the next item spawns. [Optional, default: 80] */
spawnDistance?: number;
/** Max random tilt applied to each item in degrees. [Optional, default: 18] */
rotationRange?: number;
/** External spawn area. Defaults to the component's own container. [Optional] */
containerRef?: RefObject<HTMLElement | null>;
className?: string;
children?: ReactNode;
}
interface TrailItem {
id: number;
x: number;
y: number;
rotation: number;
itemIndex: number;
}
/* ------------------------------------------------------------------ */
/* PointerTrailGallery */
/* ------------------------------------------------------------------ */
/**
* Spawns a trail of images along the pointer path: every `spawnDistance`
* pixels of travel drops the next item with a random tilt, older items
* shrink and blur away, and leaving the area clears the trail. Uses
* pointer events so pen and touch drags leave a trail too, and stays
* quiet under prefers-reduced-motion.
* @param {ReactNode[]} items - Visuals cycled through the trail [Required]
* @param {number} itemSize - Item width in px [Optional, default: 120]
* @param {number} trailLength - Max items on screen [Optional, default: 8]
* @param {number} spawnDistance - Travel between spawns [Optional, default: 80]
* @param {number} rotationRange - Max tilt in degrees [Optional, default: 18]
*/
export function PointerTrailGallery({
items,
itemSize = 120,
trailLength = 8,
spawnDistance = 80,
rotationRange = 18,
containerRef,
className,
children,
}: PointerTrailGalleryProps) {
const [trail, setTrail] = useState<TrailItem[]>([]);
const lastPos = useRef<{ x: number; y: number } | null>(null);
const counters = useRef({ id: 0, item: 0 });
const ownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = containerRef?.current ?? ownRef.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const onLeave = () => {
lastPos.current = null;
setTrail([]);
};
const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (lastPos.current) {
const dist = Math.hypot(x - lastPos.current.x, y - lastPos.current.y);
if (dist < spawnDistance) return;
}
lastPos.current = { x, y };
const c = counters.current;
const spawned: TrailItem = {
id: ++c.id,
x,
y,
rotation: (Math.random() * 2 - 1) * rotationRange,
itemIndex: c.item++ % items.length,
};
setTrail((prev) => [...prev, spawned].slice(-trailLength));
};
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerleave", onLeave);
return () => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerleave", onLeave);
};
}, [items.length, spawnDistance, rotationRange, trailLength, containerRef]);
const total = trail.length;
return (
<div ref={ownRef} className={cn("relative overflow-hidden", className)}>
{children}
<AnimatePresence>
{trail.map((item, i) => {
const age = total - 1 - i;
const scale = 0.6 + 0.4 * (1 - age / trailLength);
return (
<motion.div
key={item.id}
className="pointer-events-none absolute select-none"
style={{
left: item.x,
top: item.y,
width: itemSize,
x: "-50%",
y: "-50%",
zIndex: i,
}}
initial={{ opacity: 0, scale: 0.5, rotate: item.rotation * 1.5 }}
animate={{ opacity: 1, scale, rotate: item.rotation }}
exit={{
opacity: 0,
scale: 0.3,
rotate: item.rotation * 0.5,
filter: "blur(4px)",
}}
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
>
<div className="w-full [&>img]:h-auto [&>img]:w-full [&>svg]:h-auto [&>svg]:w-full">
{items[item.itemIndex]}
</div>
</motion.div>
);
})}
</AnimatePresence>
</div>
);
}
Update the import paths to match your project setup.
Similar components
Install via CLI
Resource details
PublishedJuly 16, 2026
CategoryMicro Interaction
ReactMotionTailwind CSS
Install via CLI
Resource details
PublishedJuly 16, 2026
CategoryMicro Interaction
ReactMotionTailwind CSS