Cursor Image Trail
A free cursor image trail react effect with a fixed node pool and no per-move layout reads.
Archive 07 / High Atlas
Eleven days above the treeline
Forty-one plates from the spring crossing: ridgelines at first light, the dune sea after rain, and the survey camp under a hard, clear sky.
Photographs by Lena Aldous / Imlil to Lake Ifni, 92 km
Trace the routeIssue 07, edition of 250, numbered and stamped
Customise
Code
<CursorImageTrail images={images} />Cursor Image Trail is a free cursor image trail react effect for Next.js, and the interesting part is what it refuses to do. The node count is fixed: trailLength images are rendered once and reused in order, so a pointer sweeping the panel for a minute allocates exactly as much as one that crosses it once. An emission cancels whatever fade the node it reaches was running rather than stacking another on top.
The pointer path costs nothing per move either. The root's bounds are cached on entry and refreshed by a ResizeObserver, so pointermove reads event coordinates and nothing else - no getBoundingClientRect, no forced layout, no React state. Emission is by Euclidean distance rather than by time, so a slow pointer leaves fewer images rather than a denser clump, and nothing anywhere is random: the image order and the alternating rotation are both derived from the pool index. The whole effect is gated on one media query for a fine pointer and no reduced-motion preference, so on a coarse pointer or with reduced motion nothing is created at all - no animations, no observers, no listeners. Each fade is a single Web Animation run on the compositor, so the main thread does no per-frame work while the trail fades and nothing keeps running afterwards.
Install
Add the @te registry to your components.json once:
{
"registries": {
"@te": "https://templateempire.io/r/{name}.json"
}
}Then install the component:
npx shadcn@latest add @te/cursor-image-trailUsage
import { CursorImageTrail } from "@/components/cursor-image-trail";
const images = [
{ src: "/trail/one.svg", width: 1600, height: 1000 },
{ src: "/trail/two.svg", width: 1600, height: 1000 },
{ src: "/trail/three.svg", width: 1600, height: 1000 },
];
export default function Page() {
return (
<div className="relative overflow-hidden">
<div className="pointer-events-none p-16">Your decorative copy</div>
<CursorImageTrail images={images} className="absolute inset-0" />
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| images | ReadonlyArray<{ src: string; width: number; height: number }> | required | Two to eight decorative images. Both dimensions are required and must be real numbers: they reserve each node's box before the file arrives. An entry missing either is dropped, and fewer than two valid images disables the effect entirely. |
| trailLength | number | 6 | How many nodes exist, clamped to 2-10. This is the whole pool, not a maximum: the same nodes are reused in order for as long as the pointer keeps moving. |
| spacing | number | 52 | How far the pointer must travel before the next node is emitted, in CSS pixels, clamped to 24-120. Measured as Euclidean distance, so diagonal movement emits at the same rate as straight. |
| scaleDecay | number | 0.08 | How much a node shrinks over its lifetime, clamped to 0-0.16, multiplied by the pool size and floored at 0.4 so a long trail cannot collapse to nothing. |
| imageSize | number | 112 | Maximum trail image width in CSS pixels, clamped to 64-180. |
| fadeDuration | number | 0.65 | How long a node takes to fade out, in seconds, clamped to 0.3-1.2. |
| rotation | number | 7 | Maximum rotation in degrees, clamped to 0-16. It alternates by pool index rather than varying randomly, so the trail is identical every time. |
| blendMode | "normal" | "multiply" | "screen" | "normal" | Decorative CSS blend mode for the nodes. It carries no information; nothing about the effect's meaning depends on how it blends. |
| className | string | undefined | Additional classes merged onto the bounded effect root with cn(). This is where you size and position it. |
Customisation
Cursor Image Trail declares two tokens of its own, each falling back to a global semantic token: --image-trail-outline for the node edge and --image-trail-shadow as the source colour for a low-opacity shadow. Geometry and timing come from props rather than tokens, because they are clamped. The images themselves are yours, and nothing about them is themed.
:root {
--image-trail-outline: oklch(from var(--border) l c h / 0.5);
--image-trail-shadow: var(--foreground);
}
<CursorImageTrail images={images} trailLength={9} spacing={36} blendMode="screen" />Frequently asked questions
- Is Cursor Image Trail free for commercial use?
- Yes. Cursor Image Trail is MIT licensed, so you can use it in personal and commercial projects without attribution.
- Should I put the trail over buttons and links?
- Yes, content beneath the trail stays interactive. The trail root is pointer-events-none and its parent receives the pointer for it, so text under the field stays selectable and links and buttons stay clickable and focusable. A move counts only inside the root's own box, so give the root the area you want the trail in; the images fade over whatever is beneath, so keep them clear of copy that must stay legible.
- What does it do on a phone, or with reduced motion?
- Nothing at all, and that is stronger than it sounds. The entire effect is built inside one media query gated on a fine pointer and no reduced-motion preference. Outside that query there are no animations, no ResizeObserver, no IntersectionObserver, no pointer listeners and no visibility listener - not a disabled effect but an absent one, so a phone never pays for machinery it cannot use. The same applies when fewer than two valid images are supplied. The operating system's cursor is never hidden or replaced either, so a reader using a large or high-contrast pointer keeps it.