Copied!
Gallery

Make way grid

A grid of gradient tiles where the hovered tile grows and every neighbour is pushed aside by a spring, strongest for the closest tiles, so the grid never overlaps.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

CursorElementVanilla 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 "Make way grid" for my site using the Enrichr Library element at https://enrichr.dev/elements/make-way-grid.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/make-way-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
- scale: 1.5
- push: 22
- stiffness: 0.14
- columns: 4

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/make-way-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-mwg">
  <div class="ef-mwg__wrap"><div class="ef-mwg__grid" role="list"></div></div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-mwg { width: 100%; }
.ef-mwg__wrap { box-sizing: border-box; width: 100%; min-height: 20rem; padding: 2.6rem; border-radius: 1rem; display: flex; align-items: center; overflow: hidden; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-mwg__grid { width: 100%; display: grid; grid-template-columns: repeat(var(--ef-cols, 4), minmax(0, 1fr)); gap: 0.7rem; }
.ef-mwg__tile { --x: 0px; --y: 0px; --s: 1; position: relative; display: block; aspect-ratio: 1; border-radius: 0.8rem; border: 0; padding: 0; color: #fbf8f1; cursor: pointer; font: inherit; text-align: left; transform: translate3d(var(--x), var(--y), 0) scale(var(--s)); will-change: transform; outline: none; box-shadow: 0 6px 12px -8px rgba(0, 0, 0, 0.6); }
.ef-mwg__tile.is-on { z-index: 2; box-shadow: 0 18px 28px -12px rgba(0, 0, 0, 0.7); }
.ef-mwg__tile:focus-visible { z-index: 2; box-shadow: 0 0 0 2px var(--ef-acc, #e6ff55), 0 18px 28px -12px rgba(0, 0, 0, 0.7); }
.ef-mwg__art { position: absolute; inset: 0; border-radius: inherit; overflow: hidden; }
.ef-mwg__art::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 45%, rgba(0, 0, 0, 0.55)); }
.ef-mwg__n { position: absolute; left: 0.6rem; bottom: 0.5rem; font-size: 0.7rem; font-weight: 800; letter-spacing: 0.12em; }
@media (max-width: 30rem) { .ef-mwg__wrap { padding: 1.4rem; } }
@media (prefers-reduced-motion: reduce) { .ef-mwg__tile { transform: none; } .ef-mwg__tile.is-on, .ef-mwg__tile:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc, #e6ff55); } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Make way grid: hovering (or focusing) a tile scales it up and pushes every other tile away from it, further for tiles that are close, so the grid stays intact.
// Each tile has a small spring that chases its target offset and scale; one rAF loop runs only while something is moving. Reduced motion: no movement, just a highlight.
// mount(root, { background, accent, scale (1.05-2), push (px), stiffness (0.04-0.4), columns (2-6) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-mwg__wrap");
  var grid = root.querySelector(".ef-mwg__grid");
  var opts = {}, tiles = [], offs = [], raf = 0, active = -1, dead = false;
  var NAMES = ["Tidal", "Ember", "Moss", "Dusk", "Arch", "Grain", "Haze", "Loop", "Bloom", "Slate", "Flint", "Drift"];

  // Enrichr puzzle-piece artwork, inline so the code stays portable. piece(i, accent) returns an aria-hidden SVG string, a different piece per card.
  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 PLACE = [[52, -16, 80, 14], [-24, 34, 96, -22], [46, 26, 74, 32], [-8, -20, 84, -12], [58, 38, 92, 24], [4, 18, 78, -32]]; // left %, top %, width %, rotate deg
  var PCOL = [["#2f7d5b", 0.95], ["#0c1e18", 0.8], ["ACC", 0.9], ["#c6f26b", 0.85], ["#0a6b46", 0.95], ["#fbf8f1", 0.85]];
  function piece(i, accent, sx, ox) {
    var p = PIECES[i % 3], q = PLACE[i % 6], c = PCOL[i % 6], k = sx || 1, o = ox || 0;
    return '<span class="ef-mwg__pc" aria-hidden="true" style="position:absolute;left:' + (o + q[0] * k).toFixed(1) + '%;top:' + q[1] + '%;width:' + (q[2] * k).toFixed(1) + '%;transform:rotate(' + q[3] + 'deg);color:' + (c[0] === "ACC" ? accent : c[0]) + ';opacity:' + c[1] + ';pointer-events:none"><svg viewBox="' + p[0] + '" aria-hidden="true" focusable="false" style="display:block;width:100%;height:auto"><path fill="currentColor" d="' + p[1] + '"/></svg></span>';
  }
  function art(i, a) {
    var c = "#fbf8f1", d = "#0c1e18", p = i % 6;
    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(190deg, #005236, " + a + ")";
    return "radial-gradient(circle, " + a + " 0 2px, transparent 2.5px) 0 0 / 14px 14px, linear-gradient(145deg, #005236, " + d + ")";
  }
  function clear() {
    offs.forEach(function (f) { f(); }); offs = [];
    if (raf) cancelAnimationFrame(raf); raf = 0;
  }
  function measure() {
    tiles.forEach(function (t) {
      var w = t.el.offsetWidth;
      t.size = w; t.cx = t.el.offsetLeft + w / 2; t.cy = t.el.offsetTop + t.el.offsetHeight / 2;
    });
  }
  function setTargets() {
    var h = active >= 0 ? tiles[active] : null;
    tiles.forEach(function (t, i) {
      if (!h || reduce) { t.tx = 0; t.ty = 0; t.ts = 1; return; }
      if (i === active) { t.tx = 0; t.ty = 0; t.ts = opts.scale; return; }
      var dx = t.cx - h.cx, dy = t.cy - h.cy, d = Math.sqrt(dx * dx + dy * dy) || 1;
      var f = Math.exp(-d / (h.size * 1.6));
      // every tile on a side of the grown tile yields by its extra half-size, so nothing overlaps
      var grow = h.size * (opts.scale - 1) / 2;
      var gx = grow * Math.max(-1, Math.min(1, dx / (h.size * 0.6)));
      var gy = grow * Math.max(-1, Math.min(1, dy / (h.size * 0.6)));
      var m = opts.push * (0.25 + f * 1.4);
      t.tx = dx / d * m + gx; t.ty = dy / d * m + gy; t.ts = 1;
    });
    if (!raf && !dead) raf = requestAnimationFrame(tick);
  }
  function tick() {
    raf = 0;
    var k = opts.stiffness, damp = 0.78, moving = false;
    tiles.forEach(function (t) {
      t.vx = (t.vx + (t.tx - t.x) * k) * damp; t.vy = (t.vy + (t.ty - t.y) * k) * damp; t.vs = (t.vs + (t.ts - t.s) * k) * damp;
      t.x += t.vx; t.y += t.vy; t.s += t.vs;
      if (Math.abs(t.vx) + Math.abs(t.vy) + Math.abs(t.vs * 100) > 0.02 || Math.abs(t.tx - t.x) > 0.05 || Math.abs(t.ty - t.y) > 0.05 || Math.abs(t.ts - t.s) > 0.0005) moving = true;
      else { t.x = t.tx; t.y = t.ty; t.s = t.ts; t.vx = t.vy = t.vs = 0; }
      t.el.style.setProperty("--x", t.x.toFixed(2) + "px");
      t.el.style.setProperty("--y", t.y.toFixed(2) + "px");
      t.el.style.setProperty("--s", t.s.toFixed(4));
    });
    if (moving && !dead) raf = requestAnimationFrame(tick);
  }
  function activate(i) {
    active = i;
    tiles.forEach(function (t, j) { t.el.classList.toggle("is-on", j === i); });
    measure(); setTargets();
  }
  function build() {
    clear();
    grid.textContent = "";
    tiles = [];
    active = -1;
    var n = opts.columns * 3;
    for (var i = 0; i < n; i++) (function (i) {
      var b = document.createElement("button");
      b.type = "button"; b.className = "ef-mwg__tile"; b.setAttribute("role", "listitem");
      b.setAttribute("aria-label", NAMES[i % NAMES.length] + " " + (i + 1));
      var bg = document.createElement("span"); bg.className = "ef-mwg__art"; bg.style.background = art(i, opts.accent);
      bg.insertAdjacentHTML("beforeend", piece(i, opts.accent));
      var nm = document.createElement("span"); nm.className = "ef-mwg__n"; nm.textContent = String(i + 1).padStart(2, "0");
      b.appendChild(bg); b.appendChild(nm);
      var t = { el: b, x: 0, y: 0, s: 1, vx: 0, vy: 0, vs: 0, tx: 0, ty: 0, ts: 1, cx: 0, cy: 0, size: 1 };
      function en(e) { if (e && e.pointerType === "touch") return; activate(i); }
      function lv() { if (active === i) activate(-1); }
      b.addEventListener("pointerenter", en); b.addEventListener("pointerleave", lv);
      b.addEventListener("focus", en); b.addEventListener("blur", lv);
      offs.push(function () { b.removeEventListener("pointerenter", en); b.removeEventListener("pointerleave", lv); b.removeEventListener("focus", en); b.removeEventListener("blur", lv); });
      grid.appendChild(b); tiles.push(t);
    })(i);
    var onResize = function () { measure(); setTargets(); };
    window.addEventListener("resize", onResize);
    offs.push(function () { window.removeEventListener("resize", onResize); });
    measure();
  }
  function update(next) {
    var prev = opts;
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", scale: 1.5, push: 22, stiffness: 0.14, columns: 4 }, opts, next || {});
    opts.columns = Math.max(2, Math.min(6, Math.round(opts.columns)));
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    grid.style.setProperty("--ef-cols", opts.columns);
    if (prev.columns !== opts.columns || prev.accent !== opts.accent) build();
    else if (active >= 0) setTargets();
  }
  update(o);
  return {
    update: update,
    destroy: function () {
      dead = true; clear();
      tiles.forEach(function (t) { t.el.style.removeProperty("--x"); t.el.style.removeProperty("--y"); t.el.style.removeProperty("--s"); });
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","scale":1.5,"push":22,"stiffness":0.14,"columns":4});

How it works

Each tile keeps a position, scale and velocity. When a tile is hovered or focused, the script measures every tile's centre, then works out a target for each neighbour: a direction away from the hovered tile, with a length that falls off exponentially with distance, plus extra room for the growth of the hovered tile. One requestAnimationFrame loop steps a damped spring for every tile towards its target and writes --x, --y and --s custom properties, which the stylesheet turns into a translate and scale. When all tiles settle, the loop stops. Leaving the tile sets every target back to zero and the spring eases the grid home.

Accessibility and performance

Tiles are buttons, so they are reachable by keyboard, and focusing a tile makes the grid react exactly as hover does, with a visible focus ring. Each tile has an aria-label. With prefers-reduced-motion the tiles do not move or scale; the hovered or focused tile only gets an accent outline. Hover is ignored for touch pointers.

Only transforms are animated, and only on the tiles. Layout is measured once per hover change, not per frame, and the rAF loop sleeps when every spring has settled. Keep the tile count modest (under about 40) as every tile is updated each frame.

SectionGallery
TechniquesPointer interaction, Stagger
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byMake Way Grid Effect 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.

Distributed letters hoverA big headline whose letters scatter evenly across the stage on hover or button press, then spring back into the word. Repetition image hover trailGradient image tiles that spawn a staggered trail of repeated copies chasing the pointer, each with a little more lag, then fading out on leave. FLIP filter gridA tile grid with category filter buttons: filtering animates the remaining tiles to their new positions with a hand-rolled FLIP while others fade out and in.

FAQ

Why does the grid not overlap?

Neighbours are pushed away by at least the growth of the hovered tile, and the push is largest for the closest ones, so the gap opens where the tile expands.

Can I use images in the tiles?

Yes. Replace the background on the art layer with an image. The tile only needs a width, aspect ratio and the transform variables.

How do I make it springier?

Raise the stiffness for a snappier reaction. The damping is fixed in element.js and can be lowered for more overshoot.