Copied!
Background

Image grid motion on pointer move

A background grid of puzzle-piece tiles that scale in from the centre and then drift against the pointer, each tile with its own reach so the whole grid shears and fans.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

3DElementVanilla 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 grid motion on pointer move" for my site using the Enrichr Library element at https://enrichr.dev/elements/image-grid-motion.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/image-grid-motion.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
- amount: 50
- ease: 0.07
- spin: 0
- cols: 6

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-grid-motion.json.

Code

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

HTML

<div class="ef-igm">
  <svg width="0" height="0" aria-hidden="true" focusable="false" style="position:absolute">
    <defs>
      <symbol id="ef-igm-p1" viewBox="1513.7 1453.9 322.3 322.3"><path fill="currentColor" d="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"/></symbol>
      <symbol id="ef-igm-p4" viewBox="1423.36 860.8 503.07 322.3"><path fill="currentColor" d="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"/></symbol>
      <symbol id="ef-igm-p8" viewBox="1513.7 166.86 322.3 503.06"><path fill="currentColor" d="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"/></symbol>
    </defs>
  </svg>
  <div class="ef-igm__stage" tabindex="0" role="group" aria-label="Drifting puzzle piece grid. Move the pointer or use the arrow keys to shift the pieces.">
    <div class="ef-igm__grid" aria-hidden="true"></div>
    <div class="ef-igm__copy">
      <h3 class="ef-igm__title">Piece by piece</h3>
      <p class="ef-igm__sub">Move across the frame and the grid follows.</p>
    </div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
/* Adapted from https://github.com/codrops/ImageGridMotionEffect (MIT) — Copyright (c) 2009 - 2020 [Codrops](https://tympanus.net/codrops) */
.ef-igm { width: 100%; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-igm__stage { position: relative; box-sizing: border-box; width: 100%; min-height: 20rem; border-radius: 1rem; overflow: hidden; background: #0c1e18; color: #fbf8f1; outline: none; isolation: isolate; }
.ef-igm__stage:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-igm__grid { position: absolute; width: 112%; height: 112%; left: -6%; top: -6%; display: grid; gap: 0.7rem; pointer-events: none; }
.ef-igm__tile { position: relative; display: flex; align-items: center; justify-content: center; min-width: 0; min-height: 0; padding: 0.35rem; opacity: 0; will-change: transform, opacity; }
.ef-igm__tile svg { width: 100%; height: 100%; display: block; overflow: visible; }
.ef-igm__copy { position: absolute; inset: 0; z-index: 2; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 1.2rem; pointer-events: none; background: radial-gradient(ellipse 60% 50% at 50% 50%, rgba(12, 30, 24, 0.78), rgba(12, 30, 24, 0) 75%); }
.ef-igm__title { margin: 0; font-size: clamp(1.5rem, 5.5vw, 2.4rem); font-weight: 800; letter-spacing: -0.02em; line-height: 1.05; }
.ef-igm__sub { margin: 0.5rem 0 0; font-size: 0.85rem; opacity: 0.8; letter-spacing: 0.02em; }
@media (prefers-reduced-motion: reduce) { .ef-igm__tile { will-change: auto; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Adapted from https://github.com/codrops/ImageGridMotionEffect (MIT) — Copyright (c) 2009 - 2020 [Codrops](https://tympanus.net/codrops)
// Image grid motion: a background grid of puzzle-piece tiles that scale in from the centre, then drift against the pointer.
// Each tile has its own random reach, so the grid shears and fans as the pointer crosses the frame. One shared lerped pointer value
// drives every tile through a single rAF loop that sleeps once the grid has settled. Pointer events are read on the element (not the
// window), arrow keys nudge the grid for keyboard users, and reduced motion keeps the tiles still.
// mount(root, { background, accent, amount (px), ease (0.02-0.25), spin (deg), cols (3-8) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var stage = root.querySelector(".ef-igm__stage");
  var grid = root.querySelector(".ef-igm__grid");
  var opts = {}, tiles = [], raf = 0, t0 = 0, last = 0, introDone = false, dead = false;
  var cur = { x: 0, y: 0 }, tgt = { x: 0, y: 0 };
  var SYM = ["ef-igm-p1", "ef-igm-p4", "ef-igm-p8"];
  var ALPHA = 0.78;

  function rnd(a, b) { return a + Math.random() * (b - a); }
  function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
  function expoOut(t) { return t >= 1 ? 1 : 1 - Math.pow(2, -10 * t); }

  function build() {
    grid.textContent = "";
    tiles = [];
    var cols = opts.cols, rows = Math.max(3, Math.round(cols * 0.75));
    grid.style.gridTemplateColumns = "repeat(" + cols + ", 1fr)";
    grid.style.gridTemplateRows = "repeat(" + rows + ", 1fr)";
    var cream = "#fbf8f1", pal = ["#0a6b46", "#2f7d5b", "#005236", opts.accent, "#0a6b46", "#c6f26b", "#2f7d5b", cream, "#005236"];
    for (var i = 0; i < cols * rows; i++) {
      var r = Math.floor(i / cols), c = i % cols;
      var el = document.createElement("div");
      el.className = "ef-igm__tile";
      el.style.color = pal[(i * 7 + r * 3 + c) % pal.length];
      el.innerHTML = '<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false"><use width="100" height="100" href="#' + SYM[(r + c * 2) % 3] + '"></use></svg>';
      var dx = (c + 0.5) / cols - 0.5, dy = (r + 0.5) / rows - 0.5;
      var t = { el: el, fx: rnd(0.25, 1), fy: rnd(0.25, 1.5), fr: rnd(-1, 1), delay: Math.sqrt(dx * dx + dy * dy) * 900 + rnd(0, 150) };
      grid.appendChild(el);
      tiles.push(t);
    }
    t0 = performance.now();
    introDone = reduce;
    paint(1, true);
    wake();
  }

  function paint(introAll, force) {
    var now = performance.now();
    var allIn = true;
    for (var i = 0; i < tiles.length; i++) {
      var t = tiles[i];
      var p = reduce ? 1 : clamp((now - t0 - t.delay) / 1700, 0, 1);
      if (p < 1) allIn = false;
      var e = expoOut(p);
      var s = 0.7 + 0.3 * e;
      var x = reduce ? 0 : cur.x * t.fx * opts.amount;
      var y = reduce ? 0 : cur.y * t.fy * opts.amount;
      var rot = reduce ? 0 : cur.x * t.fr * opts.spin;
      t.el.style.opacity = (ALPHA * clamp(p * 2, 0, 1)).toFixed(3);
      t.el.style.transform = "translate3d(" + x.toFixed(2) + "px," + y.toFixed(2) + "px,0) rotate(" + rot.toFixed(2) + "deg) scale(" + s.toFixed(3) + ")";
    }
    if (introAll && !force) introDone = allIn;
    return allIn;
  }

  function frame(now) {
    raf = 0;
    if (dead) return;
    var dt = last ? Math.min(3, (now - last) / 16.667) : 1;
    last = now;
    var k = 1 - Math.pow(1 - opts.ease, dt);
    cur.x += (tgt.x - cur.x) * k;
    cur.y += (tgt.y - cur.y) * k;
    var settled = Math.abs(tgt.x - cur.x) < 0.0008 && Math.abs(tgt.y - cur.y) < 0.0008;
    if (settled) { cur.x = tgt.x; cur.y = tgt.y; }
    paint(true, false);
    if (!introDone || !settled) raf = requestAnimationFrame(frame);
    else last = 0;
  }
  function wake() { if (!raf && !dead) { last = 0; raf = requestAnimationFrame(frame); } }

  function point(e) {
    if (reduce) return;
    var r = stage.getBoundingClientRect();
    tgt.x = clamp(((e.clientX - r.left) / r.width) * 2 - 1, -1, 1);
    tgt.y = clamp(((e.clientY - r.top) / r.height) * 2 - 1, -1, 1);
    wake();
  }
  function leave() { tgt.x = 0; tgt.y = 0; wake(); }
  function key(e) {
    if (reduce) return;
    var s = 0.35, h = true;
    if (e.key === "ArrowLeft") tgt.x = clamp(tgt.x - s, -1, 1);
    else if (e.key === "ArrowRight") tgt.x = clamp(tgt.x + s, -1, 1);
    else if (e.key === "ArrowUp") tgt.y = clamp(tgt.y - s, -1, 1);
    else if (e.key === "ArrowDown") tgt.y = clamp(tgt.y + s, -1, 1);
    else if (e.key === "Home" || e.key === "Escape") { tgt.x = 0; tgt.y = 0; }
    else h = false;
    if (h) { e.preventDefault(); wake(); }
  }
  stage.addEventListener("pointermove", point);
  stage.addEventListener("pointerdown", point);
  stage.addEventListener("pointerleave", leave);
  stage.addEventListener("keydown", key);

  function update(next) {
    var prev = opts;
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", amount: 50, ease: 0.07, spin: 0, cols: 6 }, opts, next || {});
    opts.cols = Math.max(3, Math.min(8, Math.round(opts.cols)));
    opts.ease = clamp(Number(opts.ease), 0.02, 0.25);
    stage.style.background = opts.background;
    stage.style.setProperty("--ef-acc", opts.accent);
    if (prev.cols !== opts.cols || prev.accent !== opts.accent) build();
    else { paint(false, true); wake(); }
  }
  function destroy() {
    dead = true;
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
    stage.removeEventListener("pointermove", point);
    stage.removeEventListener("pointerdown", point);
    stage.removeEventListener("pointerleave", leave);
    stage.removeEventListener("keydown", key);
  }
  update(o);
  return { update: update, destroy: destroy };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","amount":50,"ease":0.07,"spin":0,"cols":6});

How it works

A vanilla port with no GSAP. The pointer position over the frame is turned into a -1 to 1 pair, and one shared value is lerped toward it every frame. Each tile multiplies that shared value by its own random reach on x and y, plus an optional rotation, and writes a single translate, rotate and scale transform. The intro scales the tiles from 0.7 to 1 and fades them in with an exponential ease, delayed by each tile's distance from the centre. The rAF loop sleeps once the intro has finished and the grid has settled, and wakes on the next pointer or key event.

Accessibility and performance

The frame is focusable and arrow keys nudge the grid, so the effect is not pointer only; Home or Escape recentres it. The tiles are decorative and hidden from assistive tech. Under reduced motion the tiles appear in place with no intro and no drift. Keep the foreground text on a darkened scrim so it stays readable over the moving tiles.

One rAF loop writes one transform per tile, with about thirty tiles by default. Only transform and opacity change, and the loop stops when everything has settled. Pointer events are read on the frame, not the window, so it works inside overflow hidden containers. Keep the tile count modest on low-end phones.

SectionBackground
TechniquesPointer interaction, Parallax and depth, Stagger
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byImage Grid Motion Effect by Codrops on github.com. 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.

Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. Depth map shaderA WebGL shader that shifts an image by its own depth and the pointer, so near parts move more than far ones. Cutout collage gridAn editorial collage built with CSS grid and clip-path shapes over generated art, with a gentle depth drift on pointer move.

FAQ

Why does each tile move by a different amount?

Each tile gets a random reach when the grid is built. Because they all follow the same lerped pointer value, the differing reach is what creates the shearing, fanning look.

Can I use photos instead of puzzle pieces?

Yes. Replace the svg in each tile with an img or a background-image element. The motion only touches the tile wrapper, so any content works.

Why listen on the frame and not the window?

So the effect still works when the element sits inside a container with overflow hidden or an iframe, and so several instances on a page do not all react to the same pointer.