Cursor
PointerA custom pointer that trails the real one: a dot, a ring that grows over links and buttons, or a label ("View") any element can ask for with data-cursor-label.
Small images pop up along the pointer's path and fade behind it. The images are a server-rendered pool of lazy <img> elements; every `threshold` px the script positions the next one and restarts its keyframes, so nothing is created on move. The pop and the fade are CSS on the preset curve, the lifetime a duration token. On touch and under reduced motion the first image simply rests in the centre, under the slotted content.
Move across.
a photo every 70 px
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
872 B raw
504 B gzip.
Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.5 kB raw.
CSS 5.2 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 105 · Firefox 110 · Safari 16; needs container-units.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import ImageTrail from '@moonarc/core/ImageTrail';
---
<ImageTrail images={['/a.jpg', '/b.jpg', '/c.jpg', '/d.jpg']} class="h-[28rem]">
<h2 class="relative">Move over me</h2>
</ImageTrail>Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/image-trail.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts and add the JS gate to your head.
---
/**
* ImageTrail — small images pop up along the pointer's path and fade away
* behind it. The images are a server-rendered pool (one <img> each, lazy,
* hidden); the script only writes a position and restarts one keyframe on
* the next image in the pool every time the pointer has travelled
* `threshold` px, so nothing is created on move (a move reads the box's
* position, a restart one offsetWidth). The pop and the fade are CSS on
* the preset's curve, the lifetime is a duration token. Only listens
* inside its own box. On touch and coarse pointers the script never binds,
* under reduced motion (read on every move) it spawns nothing, and the
* first image sits still in the centre, under the slotted content — the
* same thing the HTML shows without JavaScript.
*/
import type { HTMLAttributes, HTMLTag } from 'astro/types';
interface Image {
src: string;
alt?: string;
}
interface Props extends HTMLAttributes<'div'> {
/** Element to render. */
as?: HTMLTag;
/** Up to 12 images (URLs or { src, alt }); the trail cycles through them. */
images: (string | Image)[];
/** Pointer travel in px between two images. */
threshold?: number;
/** How long an image stays: a duration token, not milliseconds. */
lifetime?: 'fast' | 'base' | 'slow';
/** Image width in px; height follows the image. */
size?: number;
/** Maximum tilt in degrees; each image leans with the direction the pointer was moving. */
tilt?: number;
}
const { as: Tag = 'div', images, threshold = 80, lifetime = 'base', size = 140, tilt = 8, class: className, style, ...rest } = Astro.props;
const pool: Image[] = images.slice(0, 12).map((i) => (typeof i === 'string' ? { src: i } : i));
const inline = [`--ma-trail-threshold:${threshold}`, `--ma-trail-size:${size}px`, `--ma-trail-tilt:${tilt}deg`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<Tag class:list={['ma-trail', className]} data-ma-trail data-life={lifetime} style={inline} {...rest}>
<slot />
<span class="ma-trail__pool" aria-hidden="true">
{pool.map((img, i) => <img src={img.src} alt={img.alt ?? ''} loading="lazy" decoding="async" draggable="false" style={`--ma-i:${i}`} />)}
</span>
</Tag>
<style is:global>
@layer components {
:where(.ma-trail) {
position: relative;
isolation: isolate;
overflow: hidden;
/* the lifetime is a token; the pop is the preset's own curve */
--ma-trail-life: var(--ma-dur-ambient);
}
:where(.ma-trail[data-life='fast']) {
--ma-trail-life: var(--ma-duration);
}
:where(.ma-trail[data-life='slow']) {
--ma-trail-life: calc(var(--ma-dur-ambient) * 2);
}
:where(.ma-trail__pool) {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
/* the pool is the container the resting image is centred in (cqw / cqh below) */
container-type: size;
}
:where(.ma-trail__pool img) {
position: absolute;
left: 0;
top: 0;
width: var(--ma-trail-size, 140px);
height: auto;
border-radius: 0.5rem;
object-fit: cover;
opacity: 0;
/* --ma-trail-x / --ma-trail-y: the spawn point; --ma-trail-r: the lean, both written by the script */
translate: calc(var(--ma-trail-x, 50cqw) - 50%) calc(var(--ma-trail-y, 50cqh) - 50%);
rotate: var(--ma-trail-r, 0deg);
will-change: opacity, scale;
}
:where(.ma-trail__pool img[data-live]) {
animation: ma-trail var(--ma-trail-life) var(--ma-ease-out) both;
}
/* no script (or none allowed): the first image, still, in the centre — under the slotted content, which it would cover */
:where(html:not([data-ma-js]) .ma-trail__pool) {
z-index: -1;
}
:where(html:not([data-ma-js]) .ma-trail__pool img:first-child) {
opacity: 1;
}
@media (hover: none), (pointer: coarse), (prefers-reduced-motion: reduce) {
:where(.ma-trail__pool) {
z-index: -1;
}
:where(.ma-trail__pool img) {
animation: none;
opacity: 0;
}
/* centred and upright even when the trail has used it: reduced motion can be switched on after a few spawns */
:where(.ma-trail__pool img:first-child) {
opacity: 1;
translate: calc(50cqw - 50%) calc(50cqh - 50%);
rotate: 0deg;
}
}
}
@keyframes ma-trail {
0% {
opacity: 0;
scale: 0.6;
}
18% {
opacity: 1;
scale: 1;
}
70% {
opacity: 1;
scale: 1;
}
100% {
opacity: 0;
scale: 1.04;
}
}
</style>
<script>
import { onMount, prefersReducedMotion } from '../lib/runtime';
onMount<HTMLElement>('[data-ma-trail]', (el, { signal }) => {
if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return;
const imgs = el.querySelectorAll<HTMLElement>('.ma-trail__pool img');
if (!imgs.length) return;
const threshold = Number(el.style.getPropertyValue('--ma-trail-threshold')) || 80;
let lx = -1e9;
let ly = 0;
let n = 0;
el.addEventListener(
'pointermove',
(e) => {
// read on every move, so the page's motion switch applies both ways without a reload
if (prefersReducedMotion()) return;
const r = el.getBoundingClientRect();
const x = e.clientX - r.left;
const y = e.clientY - r.top;
const dx = x - lx;
const dy = y - ly;
if (dx * dx + dy * dy < threshold * threshold) return;
lx = x;
ly = y;
const img = imgs[n++ % imgs.length]!;
img.removeAttribute('data-live');
void img.offsetWidth; // restart the keyframes on a recycled image
img.style.setProperty('--ma-trail-x', `${x}px`);
img.style.setProperty('--ma-trail-y', `${y}px`);
img.style.setProperty('--ma-trail-r', `calc(${Math.max(-1, Math.min(1, dx / threshold)).toFixed(2)} * var(--ma-trail-tilt))`);
img.setAttribute('data-live', '');
},
{ signal, passive: true },
);
el.addEventListener('animationend', (e) => (e.target as HTMLElement).removeAttribute('data-live'), { signal });
});
</script>| Prop | Type | Default | Description |
|---|---|---|---|
images | (string | { src, alt? })[] | none | Up to 12 images; the trail cycles through them in order. |
threshold | number | 80 | Pointer travel in px between two images. Lower is denser. |
lifetime | 'fast' | 'base' | 'slow' | 'base' | How long an image stays, as a duration token: the preset duration, the ambient duration, or twice it. |
size | number | 140 | Image width in px; the height follows the image. |
tilt | number | 8 | Maximum lean in degrees; each image tilts with the direction the pointer was moving. |
as | HTMLTag | 'div' | Element to render. |
Nothing spawns (the script reads the setting on every move, so the page's own switch applies both ways without a reload); the first image sits still in the centre of the box, under the slotted content, which is also what the HTML shows without JavaScript and on touch.
ClientRouterBound through the shared runtime: the listeners are aborted before the swap; the pool is server HTML on every page.
Replaces: ImageTrail (React Bits) · ImageTrail (Motion Primitives) · Image trail (Codrops). See the migration table.
A custom pointer that trails the real one: a dot, a ring that grows over links and buttons, or a label ("View") any element can ask for with data-cursor-label.
A soft radial light follows the cursor over a card and fades in only while hovered.
A card leans toward the cursor in 3D, lifts slightly, catches a glare, and settles back on the active preset's spring when the cursor leaves.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown