# Image Trail (Moonarc)

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.

- Import: `import ImageTrail from '@moonarc/core/ImageTrail'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/image-trail.json`
- Tier B · category pointer · trigger pointer
- Readout: `<ImageTrail images={[…]} threshold={80}>`
- Browser support: widely (Chrome 105 · Firefox 110 · Safari 16)
- Measured cost: 872 B raw JS · 504 B gzip · + runtime (with dependencies 2.5 kB raw; CSS 5.2 kB raw)
- Page: https://moonarc.dev/components/image-trail/

## Props

| 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. |

## Usage

```astro
<ImageTrail images={['/a.jpg', '/b.jpg', '/c.jpg', '/d.jpg']} class="h-[28rem]">
  <h2 class="relative">Move over me</h2>
</ImageTrail>
```

## 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.

## Craft

- The images are a pool: they exist in the HTML, lazy and hidden, and the script only moves the next one and restarts its animation. The trail never allocates, never decodes on move, and the first image is there without JavaScript. Each move reads the box's position once (getBoundingClientRect), and a restart reads offsetWidth once.
- Spawn on distance, not on time: an image every N px reads as a trail at any pointer speed; an image every N ms reads as a stutter when the pointer is slow.
- The lean follows the pointer's direction, clamped to ±tilt, so the trail bends the way the hand moved.
- Pop on the exit curve, hold, then fade: three phases in one keyframe set on the lifetime token, so a preset or an override changes all of them at once.
- The pool is a size container so the resting image is centred with cqw / cqh and no measurement. At rest the pool drops under the slotted content (z-index -1 inside the isolated box), so a heading in the middle stays readable; live, the trail passes over it.

## Replaces

- ImageTrail (React Bits)
- ImageTrail (Motion Primitives)
- Image trail (Codrops)

## Source

```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>

```
