Copied!
Gallery

Draggable infinite grid

A wrapping grid of gradient tiles you drag in any direction with inertia, with tiles scaling slightly by distance from the centre.

← 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 "Draggable infinite grid" for my site using the Enrichr Library element at https://enrichr.dev/elements/draggable-infinite-grid.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/draggable-infinite-grid.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: 130
- gap: 14
- falloff: 0.35
- friction: 0.94

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/draggable-infinite-grid.json.

Code

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

HTML

<div class="ef-dig">
  <div class="ef-dig__wrap">
    <div class="ef-dig__stage" tabindex="0" role="group" aria-label="Infinite tile grid. Drag, scroll, or use the arrow keys to move around.">
      <div class="ef-dig__layer"></div>
    </div>
    <p class="ef-dig__hint">Drag in any direction</p>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-dig { width: 100%; }
.ef-dig__wrap { position: relative; box-sizing: border-box; width: 100%; padding: 1rem; border-radius: 1rem; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #fbf8f1; }
.ef-dig__stage { position: relative; width: 100%; height: 22rem; border-radius: 0.8rem; overflow: hidden; cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; outline: none; background: rgba(0, 0, 0, 0.18); }
.ef-dig__stage.is-drag { cursor: grabbing; }
.ef-dig__stage:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-dig__layer { position: absolute; inset: 0; }
.ef-dig__tile { isolation: isolate; position: absolute; left: 0; top: 0; border-radius: 0.7rem; overflow: hidden; will-change: transform; backface-visibility: hidden; }
.ef-dig__tile::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(0, 0, 0, 0.1), transparent 40%, rgba(0, 0, 0, 0.5)); }
.ef-dig__n { position: absolute; left: 0.6rem; bottom: 0.5rem; z-index: 1; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.14em; opacity: 0.9; }
.ef-dig__hint { position: absolute; left: 50%; bottom: 1.6rem; transform: translateX(-50%); margin: 0; padding: 0.3rem 0.8rem; border-radius: 999px; background: rgba(12, 30, 24, 0.75); font-size: 0.72rem; letter-spacing: 0.08em; pointer-events: none; transition: opacity 400ms; }
.ef-dig__hint.is-gone { opacity: 0; }
@media (prefers-reduced-motion: reduce) { .ef-dig__hint { transition: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Draggable infinite grid: a field of gradient tiles you drag in any direction with inertia. Tile positions wrap with a modulo so the
// grid tiles forever, and each tile scales down a little by its distance from the centre. DOM transforms only, no WebGL.
// Reduced motion keeps dragging but drops the inertia and the centre scaling. Wheel and arrow keys also move the grid.
// mount(root, { background, accent, tileSize (px), gap (px), falloff (0-0.7), friction (0.8-0.98) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-dig__wrap");
  var stage = root.querySelector(".ef-dig__stage");
  var layer = root.querySelector(".ef-dig__layer");
  var hint = root.querySelector(".ef-dig__hint");
  var opts = {}, nodes = [], offs = [], ro = null, raf = 0, builtKey = "";
  var ox = 0, oy = 0, vx = 0, vy = 0, drag = false, lx = 0, ly = 0, lt = 0, last = 0, cell = 140, cols = 6, rows = 6, w = 600, h = 352;

  // 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 % 8;
    if (p === 0) return "radial-gradient(circle at 70% 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 10px, " + d + " 10px 20px)";
    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(135deg, " + a + " 0 50%, #005236 50% 100%)";
    if (p === 5) return "radial-gradient(circle, " + a + " 0 2px, transparent 2.5px) 0 0 / 14px 14px, linear-gradient(145deg, #005236, " + d + ")";
    if (p === 6) return "radial-gradient(circle at 50% 120%, " + a + " 0 40%, #0a6b46 41% 70%, " + d + " 71%)";
    return "linear-gradient(200deg, " + c + " 0 18%, #005236 18% 60%, " + d + " 60%)";
  }
  function mod(a, n) { return ((a % n) + n) % n; }
  function render() {
    var tile = opts.tileSize, hw = w / 2, hh = h / 2, maxd = Math.sqrt(hw * hw + hh * hh), pw = cols * cell, ph = rows * cell;
    for (var k = 0; k < nodes.length; k++) {
      var n = nodes[k];
      var x = mod(n.c * cell + ox, pw) - cell, y = mod(n.r * cell + oy, ph) - cell;
      var s = 1;
      if (!reduce) {
        var dx = x + tile / 2 - hw, dy = y + tile / 2 - hh;
        s = 1 - opts.falloff * Math.min(1, Math.sqrt(dx * dx + dy * dy) / maxd);
      }
      n.el.style.transform = "translate3d(" + x.toFixed(1) + "px," + y.toFixed(1) + "px,0) scale(" + s.toFixed(3) + ")";
    }
  }
  function tick(now) {
    raf = 0;
    var dt = Math.min(50, now - (last || now)) / 16.67;
    last = now;
    if (!drag) {
      ox += vx * dt; oy += vy * dt;
      var f = Math.pow(opts.friction, dt);
      vx *= f; vy *= f;
      if (Math.abs(vx) < 0.05 && Math.abs(vy) < 0.05) { vx = 0; vy = 0; }
    }
    render();
    if (drag || vx || vy) raf = requestAnimationFrame(tick);
    else last = 0;
  }
  function kick() { if (!raf) raf = requestAnimationFrame(tick); }
  function clear() {
    offs.forEach(function (f) { f(); }); offs = [];
    if (raf) cancelAnimationFrame(raf);
    raf = 0; last = 0; vx = 0; vy = 0; drag = false;
    if (ro) { ro.disconnect(); ro = null; }
  }
  function gone() { hint.classList.add("is-gone"); }
  function on(el, ev, fn, opt) { el.addEventListener(ev, fn, opt); offs.push(function () { el.removeEventListener(ev, fn, opt); }); }
  function build() {
    var wasOx = ox, wasOy = oy;
    var keepRo = false;
    // rebuild DOM and listeners, keeping the scroll offset
    offs.forEach(function (f) { f(); }); offs = [];
    if (ro) { ro.disconnect(); ro = null; }
    w = stage.clientWidth || 600; h = stage.clientHeight || 352;
    cell = opts.tileSize + opts.gap;
    cols = Math.max(6, Math.ceil(w / cell) + 3); rows = Math.max(6, Math.ceil(h / cell) + 3);
    layer.textContent = ""; nodes = [];
    for (var r = 0; r < rows; r++) for (var c = 0; c < cols; c++) {
      var el = document.createElement("div");
      el.className = "ef-dig__tile";
      el.style.width = opts.tileSize + "px"; el.style.height = opts.tileSize + "px";
      el.style.background = art(c * 3 + r * 5 + (r & 1), opts.accent);
      el.insertAdjacentHTML("afterbegin", piece(c * 3 + r * 5 + (r & 1), opts.accent));
      var n = document.createElement("span"); n.className = "ef-dig__n";
      n.textContent = String(((c * 7 + r * 3) % 24) + 1).padStart(2, "0");
      el.appendChild(n); layer.appendChild(el);
      nodes.push({ el: el, c: c, r: r });
    }
    ox = wasOx; oy = wasOy;
    on(stage, "pointerdown", function (e) {
      if (e.button > 0) return;
      drag = true; lx = e.clientX; ly = e.clientY; lt = performance.now(); vx = 0; vy = 0;
      stage.classList.add("is-drag"); gone();
      try { stage.setPointerCapture(e.pointerId); } catch (_) {}
      kick();
    });
    on(stage, "pointermove", function (e) {
      if (!drag) return;
      var now = performance.now(), dx = e.clientX - lx, dy = e.clientY - ly, dt = Math.max(1, now - lt);
      ox += dx; oy += dy;
      vx = vx * 0.6 + (dx / dt * 16.67) * 0.4; vy = vy * 0.6 + (dy / dt * 16.67) * 0.4;
      lx = e.clientX; ly = e.clientY; lt = now;
      kick();
    });
    var up = function (e) {
      if (!drag) return;
      drag = false; stage.classList.remove("is-drag");
      try { stage.releasePointerCapture(e.pointerId); } catch (_) {}
      if (performance.now() - lt > 80) { vx = 0; vy = 0; }
      if (reduce) { vx = 0; vy = 0; }
      kick();
    };
    on(stage, "pointerup", up); on(stage, "pointercancel", up);
    on(stage, "wheel", function (e) {
      e.preventDefault(); gone();
      ox -= e.deltaX; oy -= e.deltaY;
      kick();
    }, { passive: false });
    on(stage, "keydown", function (e) {
      var step = cell * 0.9, dx = 0, dy = 0;
      if (e.key === "ArrowLeft") dx = step; else if (e.key === "ArrowRight") dx = -step;
      else if (e.key === "ArrowUp") dy = step; else if (e.key === "ArrowDown") dy = -step;
      else return;
      e.preventDefault(); gone();
      if (reduce) { ox += dx; oy += dy; kick(); } else { vx += dx * 0.11; vy += dy * 0.11; kick(); }
    });
    if (typeof ResizeObserver !== "undefined") {
      ro = new ResizeObserver(function () {
        if ((stage.clientWidth || 600) !== w || (stage.clientHeight || 352) !== h) { build(); }
      });
      ro.observe(stage);
    } else {
      var rs = function () { build(); };
      on(window, "resize", rs);
    }
    builtKey = opts.tileSize + "|" + opts.gap + "|" + opts.accent;
    render();
  }
  function update(next) {
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", tileSize: 130, gap: 14, falloff: 0.35, friction: 0.94 }, opts, next || {});
    opts.tileSize = Math.max(60, Math.min(240, +opts.tileSize));
    opts.gap = Math.max(0, Math.min(60, +opts.gap));
    opts.falloff = Math.max(0, Math.min(0.7, +opts.falloff));
    opts.friction = Math.max(0.8, Math.min(0.99, +opts.friction));
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    var key = opts.tileSize + "|" + opts.gap + "|" + opts.accent;
    if (key !== builtKey) build(); else render();
  }
  update(o);
  return { update: update, destroy: clear };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","tileSize":130,"gap":14,"falloff":0.35,"friction":0.94});

How it works

The script keeps one scroll offset for the whole field. It builds a block of tiles slightly bigger than the stage, and on each frame places every tile at its base position plus the offset, wrapped with a modulo so a tile leaving one edge re-enters at the opposite edge. That makes the grid endless without moving any DOM nodes. Dragging writes to the offset and records pointer speed; on release a requestAnimationFrame loop keeps moving the offset and multiplies the speed by a friction factor until it stops. Each tile is scaled down by its distance from the centre of the stage, and everything is applied as a translate3d plus scale transform.

Accessibility and performance

The stage is focusable and the arrow keys pan the grid, so it does not depend on a pointer. The mouse wheel also pans. Reduced motion keeps direct dragging but removes the glide and the centre scaling. The tiles are decorative; if yours carry links or captions, make them real links and keep them out of the drag handler.

Only transforms are written, and the loop runs only while dragging or gliding, then stops. The tile count is set by the stage size, not by how far you have travelled. For large photo tiles, keep the number of visible tiles modest and size the images to the tile.

SectionGallery
TechniquesPointer interaction, Parallax and depth
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byInfinite scrollable and draggable (WebGL) grid by Jesper Landberg 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.

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

Is this the same as the WebGL version?

No. It reaches a similar feel with plain DOM transforms, which keeps it light, accessible and easy to restyle, but it will not do shader effects such as per-pixel distortion.

Why do the tiles repeat?

The field wraps by design. The pattern repeats every few columns so you can drag forever. Give each tile unique art or content if you need more variety, at the cost of a larger repeating block.

Can tiles be links?

Yes. Make each tile an anchor and ignore the click when the pointer moved more than a few pixels during the drag.