Skip to content

Components / Pointer

Image Trail

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.

Live demo

Move across.

a photo every 70 px

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

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

Baseline · widely available

Chrome 105 · Firefox 110 · Safari 16; needs container-units.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/image-trail.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

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.astro
---
/**
 * 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>

Props

PropTypeDefaultDescription
images(string | { src, alt? })[]noneUp to 12 images; the trail cycles through them in order.
thresholdnumber80Pointer 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.
sizenumber140Image width in px; the height follows the image.
tiltnumber8Maximum lean in degrees; each image tilts with the direction the pointer was moving.
asHTMLTag'div'Element to render.

Reduced motion

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.

With ClientRouter

Bound through the shared runtime: the listeners are aborted before the swap; the pool is server HTML on every page.

Why it is built this way

Replaces: ImageTrail (React Bits) · ImageTrail (Motion Primitives) · Image trail (Codrops). See the migration table.

Cursor

Pointer

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.

every browserpointer

Spotlight

Pointer

A soft radial light follows the cursor over a card and fades in only while hovered.

every browserpointerhover

Tilt

Pointer

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.

every browserpointerhover

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown