Cursor Image Trail
A pointer-driven gallery: every time the pointer has moved far enough, the next image in the list is spawned at its position, rotated a little and scaled by how fast the pointer was moving. Each one fades in, holds, then shrinks away, so a short gesture leaves a trail of frames rather than a single follower.
The pool is fixed. A handful of <img> nodes are created once and reused; a spawn
only swaps src and writes a transform, so dragging across a large surface never
allocates new elements.
Usage
Pass the images and wrap the surface that should catch the pointer. The component keeps its own children, so it works as a section background as well as a card.
Move the pointer across this panel
A new portrait drops into the trail every 90 pixels of travel.
<template>
<div class="w-full">
<MCursorImageTrail
:images="images"
class="rounded-2xl border border-default bg-elevated/40"
>
<div class="flex min-h-[320px] items-center justify-center p-10">
<div class="max-w-sm text-center">
<UIcon
class="mx-auto size-8 text-primary"
name="i-lucide-mouse-pointer-2"
/>
<p class="mt-4 text-lg font-medium text-highlighted">
Move the pointer across this panel
</p>
<p class="mt-2 text-sm text-muted">
A new portrait drops into the trail every 90 pixels of travel.
</p>
</div>
</div>
</MCursorImageTrail>
</div>
</template>
<script setup lang="ts">
const images = [
{ src: 'https://randomuser.me/api/portraits/women/44.jpg', alt: 'Ana Ruiz' },
{ src: 'https://randomuser.me/api/portraits/men/32.jpg', alt: 'Ben Osei' },
{ src: 'https://randomuser.me/api/portraits/women/68.jpg', alt: 'Cleo Park' },
{ src: 'https://randomuser.me/api/portraits/men/75.jpg', alt: 'Dmitri Ilves' },
{ src: 'https://randomuser.me/api/portraits/women/90.jpg', alt: 'Elin Haugen' },
{ src: 'https://randomuser.me/api/portraits/men/11.jpg', alt: 'Farid Nazari' },
]
</script>
The effect only starts on a device with a fine pointer that can hover. On a touch screen the surface renders as ordinary content, because there is no pointer to follow.
Customize
distance controls how much travel is needed before the next frame spawns, and
lifetime controls how long it stays. Lower both for a dense, snappy trail;
raise them for a sparse, slow one. imageSize, rotate and velocityScale shape
each frame, while poolSize caps how many can be alive at once.
The frame itself is a theme slot, so a different silhouette is a class override rather than a new prop.
Smaller frames, a shorter distance and a stronger speed response make the trail feel faster and denser.
<template>
<div class="w-full">
<MCursorImageTrail
:distance="48"
:image-size="64"
:images="images"
:lifetime="520"
:pool-size="8"
:rotate="26"
:velocity-scale="0.9"
class="rounded-2xl border border-default bg-inverted"
:ui="{ image: 'rounded-full ring-2 ring-primary/60' }"
>
<div class="flex min-h-[320px] items-center justify-center p-10">
<p class="max-w-xs text-center text-sm text-inverted/60">
Smaller frames, a shorter distance and a stronger speed response make
the trail feel faster and denser.
</p>
</div>
</MCursorImageTrail>
</div>
</template>
<script setup lang="ts">
const images = [
{ src: 'https://randomuser.me/api/portraits/women/44.jpg', alt: 'Ana Ruiz' },
{ src: 'https://randomuser.me/api/portraits/men/32.jpg', alt: 'Ben Osei' },
{ src: 'https://randomuser.me/api/portraits/women/68.jpg', alt: 'Cleo Park' },
{ src: 'https://randomuser.me/api/portraits/men/75.jpg', alt: 'Dmitri Ilves' },
{ src: 'https://randomuser.me/api/portraits/women/90.jpg', alt: 'Elin Haugen' },
{ src: 'https://randomuser.me/api/portraits/men/11.jpg', alt: 'Farid Nazari' },
]
</script>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
imagesrequired | array | - | Images cycled through as the pointer moves. Each entry needs a src and an
alt. |
class | string | - | Extra classes for the tracking surface. |
distance | number | 90 | Pointer travel, in pixels, between two spawned images. |
imageSize | number | 96 | Width and height of each trailing image, in pixels. |
lifetime | number | 700 | Base lifetime of a spawned image, in milliseconds. |
poolSize | number | 12 | How many images can be on screen at once. |
rotate | number | 12 | Maximum random rotation applied to a spawned image, in degrees. |
velocityScale | number | 0.5 | How strongly pointer speed scales the image size. |
ui | object | - | Per-slot classes merged over the theme defaults. |
Slots
| Slot | Props | Description |
|---|---|---|
default | - | The content the pointer travels across. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.cursorImageTrail in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | relative overflow-hidden |
pool | - | pointer-events-none absolute inset-0 select-none |
image | - | absolute top-0 left-0 rounded-xl object-cover opacity-0 shadow-lg will-change-transform |
Accessibility
- The trail is decoration. The pool is
aria-hiddenandpointer-events-none, every frame is rendered with an emptyalt, and the surface's own content stays the accessible target. - Nothing is keyboard-triggered, because there is nothing to reach: the effect never carries information that the surrounding text does not already provide.
- Under
prefers-reduced-motion: reducethe pointer listeners and the animation loop are never attached. The frames stay at zero opacity, so the surface looks exactly like its children. - Devices without a fine pointer skip the effect for the same reason: there is no cursor to trail.
Performance
- One
requestAnimationFrameloop drives every frame. It is cancelled on unmount and whenever the effect turns off. - The container rect is read once per pointer move, never inside the loop, so the animation cannot force a layout pass.
- Frames are written straight to
style.transformandstyle.opacity; no reactive state changes per frame, so Vue does not re-render while the pointer moves. - The
<img>nodes are created once per pool size and reused. A spawn only swapssrc,altand the transform, so there is no element churn. will-change-transformis scoped to the pool frames, so the compositor hint is not spread across the page.
