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.
| Section | Gallery |
|---|---|
| Techniques | Pointer interaction, Stagger |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Make 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.
More elements
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.