Copied!
Hero

Image trail intro

An intro where gradient tiles are emitted along the pointer, or along a looping path when idle, stack and fade, and then the headline slides in.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

HeroElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Image trail intro" for my site using the Enrichr Library element at https://enrichr.dev/elements/image-trail-intro.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/image-trail-intro.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- accent: #e6ff55
- tileSize: 110
- spacing: 70
- life: 1600
- revealAfter: 10

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/image-trail-intro.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-iti">
  <div class="ef-iti__stage">
    <div class="ef-iti__pool" aria-hidden="true"></div>
    <p class="ef-iti__hint" aria-hidden="true">Move your pointer</p>
    <h2 class="ef-iti__h"><span class="ef-iti__w"><span class="ef-iti__wi" style="--i:0">Make</span></span> <span class="ef-iti__w"><span class="ef-iti__wi" style="--i:1">something</span></span> <span class="ef-iti__w"><span class="ef-iti__wi" style="--i:2">worth</span></span> <span class="ef-iti__w"><span class="ef-iti__wi" style="--i:3">keeping</span></span></h2>
    <button class="ef-iti__btn" type="button">Skip intro</button>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-iti { width: 100%; }
.ef-iti__stage { box-sizing: border-box; position: relative; width: 100%; min-height: 22rem; border-radius: 1rem; overflow: hidden; display: flex; align-items: center; justify-content: center; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #fbf8f1; isolation: isolate; }
.ef-iti__pool { position: absolute; inset: 0; pointer-events: none; z-index: 1; }
.ef-iti__tile { isolation: isolate; position: absolute; left: 0; top: 0; border-radius: 0.6rem; opacity: 0; pointer-events: none; will-change: transform, opacity; box-shadow: 0 12px 20px -10px rgba(0, 0, 0, 0.6); overflow: hidden; }
.ef-iti__tile::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255, 255, 255, 0.12), transparent 40%, rgba(0, 0, 0, 0.35)); }
.ef-iti__hint { position: absolute; z-index: 2; left: 0; right: 0; top: 50%; margin: 0; text-align: center; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.22em; text-transform: uppercase; opacity: 0.75; transform: translateY(-50%); transition: opacity 400ms; pointer-events: none; }
.ef-iti.is-revealed .ef-iti__hint { opacity: 0; }
.ef-iti__h { position: relative; z-index: 3; margin: 0; padding: 0 1.2rem; max-width: 30rem; text-align: center; font-size: clamp(1.9rem, 6vw, 3.2rem); font-weight: 800; line-height: 1.04; letter-spacing: -0.03em; pointer-events: none; text-shadow: 0 2px 18px rgba(12, 30, 24, 0.55); }
.ef-iti__w { display: inline-block; overflow: hidden; vertical-align: top; padding-bottom: 0.12em; }
.ef-iti__wi { display: inline-block; transform: translateY(115%); transition: transform 800ms cubic-bezier(.2,.8,.2,1); transition-delay: calc(var(--i, 0) * 90ms); }
.ef-iti.is-revealed .ef-iti__wi { transform: translateY(0); }
.ef-iti__btn { position: absolute; z-index: 4; right: 0.9rem; bottom: 0.9rem; padding: 0.45rem 0.85rem; border: 1px solid rgba(251, 248, 241, 0.4); border-radius: 999px; background: rgba(12, 30, 24, 0.55); color: inherit; font: inherit; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; cursor: pointer; transition: background 200ms, color 200ms; }
.ef-iti__btn:hover { background: var(--ef-acc, #e6ff55); color: #0c1e18; }
.ef-iti__btn:focus-visible { outline: 2px solid var(--ef-acc, #e6ff55); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .ef-iti__wi { transition: none; transform: none; } .ef-iti__hint { display: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Image trail intro: gradient tiles are emitted along the pointer (or along a looping path when nothing is moving), stack, then shrink and fade.
// After enough tiles have been emitted the headline words slide up and reveal. A Skip / Replay button gives keyboard users the same result.
// One rAF loop moves a fixed pool of tiles and only runs while the stage is on screen. Reduced motion shows a still cluster and the headline at once.
// mount(root, { background, accent, tileSize (px), spacing (px), life (ms), revealAfter (tiles) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var stage = root.querySelector(".ef-iti__stage");
  var pool = root.querySelector(".ef-iti__pool");
  var btn = root.querySelector(".ef-iti__btn");
  var root_ = root.querySelector(".ef-iti");
  var POOL = 18;
  var opts = {}, tiles = [], next = 0, z = 1, emitted = 0, revealed = false;
  var raf = 0, onScreen = true, io = null, dead = false, last = null, pointerAt = -1e9, t0 = 0, lastNow = 0, W = 0, H = 0;

  // Enrichr puzzle-piece artwork, inlined so the code stays portable. piece(i, accent) returns a decorative <svg> string (viewBox + one path,
  // currentColor fill), a different piece, size, rotation and crop per index. It sits behind the card text and never takes pointer events.
  var PIECES = [["1513.7 1453.9 322.3 322.3","M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"],["1423.36 860.8 503.07 322.3","M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z"],["1513.7 166.86 322.3 503.06","M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"]];
  var PLAY = [[38, -12, 95, 16, 0, 0.92], [-30, 28, 150, -22, "#2f7d5b", 0.95], [-8, -30, 80, 32, "#c6f26b", 0.8], [30, 34, 110, -8, "#0a6b46", 0.95], [-24, -10, 135, 12, "#fbf8f1", 0.2], [46, 20, 90, -28, 0, 0.85]];
  function piece(i, a) {
    var p = PIECES[(i + Math.floor(i / 3)) % 3], s = PLAY[i % 6];
    return '<svg viewBox="' + p[0] + '" aria-hidden="true" focusable="false" style="position:absolute;z-index:-1;pointer-events:none;height:auto;left:' + s[0] + '%;top:' + s[1] + '%;width:' + s[2] + '%;transform:rotate(' + s[3] + 'deg);color:' + (s[4] || a || "#e6ff55") + ';opacity:' + s[5] + '"><path fill="currentColor" d="' + p[1] + '"/></svg>';
  }

  function art(i, a) {
    var c = "#fbf8f1", d = "#0c1e18", p = i % 6;
    if (p === 0) return "radial-gradient(circle at 68% 30%, " + a + " 0 18%, transparent 19%), linear-gradient(160deg, #005236, " + d + ")";
    if (p === 1) return "conic-gradient(from 210deg at 35% 65%, #005236, " + a + ", " + c + ", #005236)";
    if (p === 2) return "repeating-linear-gradient(120deg, #005236 0 9px, " + d + " 9px 18px)";
    if (p === 3) return "radial-gradient(circle at 30% 35%, " + a + " 0 20%, transparent 21%), radial-gradient(circle at 62% 62%, " + c + " 0 20%, transparent 21%), linear-gradient(200deg, #0a6b46, " + d + ")";
    if (p === 4) return "linear-gradient(" + c + ", " + c + ") 50% 62% / 70% 36% no-repeat, linear-gradient(190deg, #005236, " + a + ")";
    return "radial-gradient(circle, " + a + " 0 2px, transparent 2.5px) 0 0 / 12px 12px, linear-gradient(145deg, #005236, " + d + ")";
  }
  function measure() { var r = stage.getBoundingClientRect(); W = r.width; H = r.height; }

  function build() {
    pool.textContent = ""; tiles = [];
    for (var i = 0; i < POOL; i++) {
      var el = document.createElement("div");
      el.className = "ef-iti__tile";
      el.style.background = art(i, opts.accent);
      el.insertAdjacentHTML("afterbegin", piece(i, opts.accent));
      pool.appendChild(el);
      tiles.push({ el: el, x: 0, y: 0, r: 0, born: 0, live: false });
    }
    next = 0;
    if (reduce) still();
  }
  function size() { var s = opts.tileSize; tiles.forEach(function (t) { t.el.style.width = s + "px"; t.el.style.height = (s * 1.25) + "px"; }); }
  function place(t, scale, op) {
    var s = opts.tileSize;
    t.el.style.opacity = op.toFixed(3);
    t.el.style.transform = "translate3d(" + (t.x - s / 2).toFixed(1) + "px," + (t.y - s * 0.625).toFixed(1) + "px,0) rotate(" + t.r.toFixed(1) + "deg) scale(" + scale.toFixed(3) + ")";
  }
  // reduced motion: a static, overlapping cluster, no movement
  function still() {
    measure();
    var pts = [[0.2, 0.3, -8], [0.8, 0.28, 6], [0.14, 0.72, 5], [0.84, 0.7, -5], [0.5, 0.86, 3]];
    tiles.forEach(function (t, i) {
      if (i >= pts.length) { t.el.style.opacity = "0"; return; }
      t.x = pts[i][0] * W; t.y = pts[i][1] * H; t.r = pts[i][2];
      place(t, 0.9, 0.9);
    });
  }
  function emit(x, y) {
    var t = tiles[next]; next = (next + 1) % POOL;
    t.x = x; t.y = y; t.r = (Math.random() - 0.5) * 22; t.born = lastNow; t.live = true;
    t.el.style.zIndex = String(z++);
    if (z > 1e6) z = 1;
    last = { x: x, y: y };
    emitted++;
    if (!revealed && emitted >= opts.revealAfter) reveal();
  }
  function reveal() {
    revealed = true;
    root_.classList.add("is-revealed");
    btn.textContent = "Replay intro";
  }
  function replay() {
    revealed = false; emitted = 0; last = null;
    root_.classList.remove("is-revealed");
    btn.textContent = "Skip intro";
    tiles.forEach(function (t) { t.live = false; t.el.style.opacity = "0"; });
    if (reduce) { still(); reveal(); }
  }
  function onBtn() {
    if (revealed) replay(); else reveal();
  }

  function onMove(e) {
    if (reduce) return;
    var r = stage.getBoundingClientRect();
    var x = e.clientX - r.left, y = e.clientY - r.top;
    pointerAt = performance.now();
    if (!last || Math.hypot(x - last.x, y - last.y) >= opts.spacing) emit(x, y);
  }
  function tick(now) {
    raf = 0;
    if (dead) return;
    lastNow = now;
    if (!t0) t0 = now;
    // idle: follow a looping path across the stage
    if (!reduce && now - pointerAt > 900) {
      var k = (now - t0) / 1000;
      var x = W * (0.5 + 0.34 * Math.cos(k * 1.3)), y = H * (0.5 + 0.3 * Math.sin(k * 1.9 + 0.6));
      if (!last || Math.hypot(x - last.x, y - last.y) >= opts.spacing) emit(x, y);
    }
    for (var i = 0; i < tiles.length; i++) {
      var t = tiles[i];
      if (!t.live) continue;
      var p = (now - t.born) / opts.life;
      if (p >= 1) { t.live = false; t.el.style.opacity = "0"; continue; }
      var inP = Math.min(p / 0.18, 1), e = 1 - Math.pow(1 - inP, 3);
      var out = p < 0.6 ? 0 : (p - 0.6) / 0.4;
      place(t, (0.55 + 0.45 * e) * (1 - 0.25 * out), 1 - out);
    }
    schedule();
  }
  function schedule() { if (!raf && !dead && !reduce && onScreen) raf = requestAnimationFrame(tick); }

  function update(nextO) {
    var prev = opts;
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", tileSize: 110, spacing: 70, life: 1600, revealAfter: 10 }, opts, nextO || {});
    stage.style.background = opts.background;
    stage.style.setProperty("--ef-acc", opts.accent);
    if (prev.accent !== opts.accent || !tiles.length) build();
    size();
    if (reduce) still();
  }
  function destroy() {
    dead = true;
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
    stage.removeEventListener("pointermove", onMove);
    btn.removeEventListener("click", onBtn);
    if (io) io.disconnect(); else window.removeEventListener("resize", measure);
    if (ro) ro.disconnect();
  }

  var ro = null;
  stage.addEventListener("pointermove", onMove);
  btn.addEventListener("click", onBtn);
  if (window.ResizeObserver) { ro = new ResizeObserver(function () { measure(); if (reduce) still(); }); ro.observe(stage); } else window.addEventListener("resize", measure);
  if (window.IntersectionObserver) {
    io = new IntersectionObserver(function (en) { onScreen = en[en.length - 1].isIntersecting; if (onScreen) schedule(); });
    io.observe(stage);
  }
  measure();
  update(o);
  if (reduce) reveal(); else schedule();
  return { update: update, destroy: destroy };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","tileSize":110,"spacing":70,"life":1600,"revealAfter":10});

How it works

A fixed pool of tiles sits inside the stage. On pointer move the script measures how far the pointer has travelled since the last tile and, once that passes the spacing value, takes the next tile from the pool, drops it at the pointer with a small random tilt and gives it a higher z-index so newer tiles stack on top. A single requestAnimationFrame loop ages every live tile: it scales and fades in quickly, holds, then shrinks and fades out over the final part of its life. If the pointer has been still for under a second the loop emits tiles along a Lissajous path instead, so the intro plays on touch screens and when nobody is moving the mouse. After a set number of tiles have been emitted, a class is added to the root and the headline words slide up from behind a clipped mask with a CSS transition staggered by a custom property.

Accessibility and performance

The headline is real text in the page from the start; it is only masked visually, so assistive tech reads it straight away. A Skip intro button, which becomes Replay intro, lets keyboard users reveal and restart the sequence without a pointer. The tiles are decorative and hidden from assistive tech. With reduced motion there is no emission or movement: a still cluster of tiles is shown and the headline appears at once. Check the headline contrast against your own tile colours.

The tiles are reused from a pool of 18, so nothing is created or removed while it plays. Only transform and opacity change. The animation loop pauses when the stage scrolls out of view and is cancelled on destroy. Large photos as tile backgrounds will cost more than gradients, so size them to the tile.

SectionHero
TechniquesPointer interaction, Reveal and intro, Stagger
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byImage Trail Animation for an Intro by Codrops on Codrops. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Expanding rounded menuA round menu button that grows into a full rounded panel with a clip-path reveal and staggered links, with Esc, outside-click and a focus trap. Glitchy grid layout slideshowA grid of puzzle-piece tiles that drift against the pointer. Switching layouts sends the tiles out, then stacks the next set in the centre and settles them into the grid, and a magnetic button fires a clipped glitch. Stagger letter swapClick to swap two screens of big type: every character slides up and out in a stagger, the background shifts colour, and the next text rises into place, with a soft cursor circle following the mouse.

FAQ

Can I use real photos?

Yes. Replace the gradient background on each tile with an image sized close to the tile. Keep the pool small so memory stays low.

How do I make the trail denser or sparser?

Change the spacing value. A smaller number emits a tile after less pointer movement, which gives a tighter trail.

Does the trail keep running after the headline appears?

Yes, the trail stays interactive underneath the headline. The tiles sit behind the text. Use the Replay button to restart the headline reveal.