Copied!
Gallery

Grid zoom layout

A grid of gradient tiles where clicking one zooms it with a FLIP transform to fill the stage, showing a title and close button while the other tiles fade.

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

Read the machine-readable spec and source at https://enrichr.dev/elements/grid-zoom-layout.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
- columns: 3
- count: 6
- duration: 600
- gap: 12

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/grid-zoom-layout.json.

Code

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

HTML

<div class="ef-gzl">
  <div class="ef-gzl__wrap">
    <div class="ef-gzl__grid"></div>
    <div class="ef-gzl__zoom" aria-hidden="true"><div class="ef-gzl__zart"></div><div class="ef-gzl__zscrim"></div><div class="ef-gzl__zbody"><span class="ef-gzl__zk"></span><h3 class="ef-gzl__zt"></h3><p class="ef-gzl__zs"></p></div><button type="button" class="ef-gzl__close" aria-label="Close">&times;</button></div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-gzl { width: 100%; }
.ef-gzl__wrap { --ef-gap: 0.8rem; --ef-cols: 3; --ef-dur: 600ms; position: relative; box-sizing: border-box; width: 100%; min-height: 20rem; padding: 1.1rem; border-radius: 1rem; overflow: hidden; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #fbf8f1; }
.ef-gzl__grid { display: grid; grid-template-columns: repeat(var(--ef-cols), minmax(0, 1fr)); gap: var(--ef-gap); }
.ef-gzl__tile { position: relative; display: block; padding: 0; border: 0; aspect-ratio: 4 / 3; border-radius: 0.7rem; overflow: hidden; cursor: pointer; color: inherit; font: inherit; text-align: left; outline: none; transition: opacity calc(var(--ef-dur) * 0.6) ease, transform 300ms cubic-bezier(.2,.8,.2,1); }
.ef-gzl__tile:hover { transform: scale(1.025); }
.ef-gzl__tile:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-gzl__art { position: absolute; inset: 0; }
.ef-gzl__art::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 45%, rgba(0, 0, 0, 0.55)); }
.ef-gzl__tt { position: absolute; left: 0.7rem; bottom: 0.55rem; right: 0.7rem; font-size: 0.9rem; font-weight: 800; letter-spacing: -0.01em; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); }
.ef-gzl__grid.is-open .ef-gzl__tile { opacity: 0.12; pointer-events: none; }
.ef-gzl__zoom { position: absolute; inset: 0; z-index: 2; border-radius: inherit; overflow: hidden; visibility: hidden; transform-origin: 0 0; will-change: transform; }
.ef-gzl__zoom.is-on { visibility: visible; }
.ef-gzl__zart, .ef-gzl__zscrim { position: absolute; inset: 0; }
.ef-gzl__zscrim { background: linear-gradient(180deg, rgba(0, 0, 0, 0.1), transparent 35%, rgba(0, 0, 0, 0.7)); }
.ef-gzl__zbody { position: absolute; left: 1.6rem; right: 1.6rem; bottom: 1.5rem; opacity: 0; transform: translateY(14px); transition: opacity calc(var(--ef-dur) * 0.3) ease, transform calc(var(--ef-dur) * 0.3) ease; }
.ef-gzl__zoom.is-shown .ef-gzl__zbody { opacity: 1; transform: none; transition: opacity calc(var(--ef-dur) * 0.5) ease calc(var(--ef-dur) * 0.45), transform calc(var(--ef-dur) * 0.6) cubic-bezier(.2,.8,.2,1) calc(var(--ef-dur) * 0.45); }
.ef-gzl__zk { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.16em; color: var(--ef-acc, #e6ff55); }
.ef-gzl__zt { margin: 0.3rem 0 0; font-size: clamp(1.6rem, 5vw, 2.6rem); line-height: 1.05; letter-spacing: -0.02em; font-weight: 800; }
.ef-gzl__zs { margin: 0.5rem 0 0; max-width: 28rem; font-size: 0.9rem; line-height: 1.45; opacity: 0.85; }
.ef-gzl__close { position: absolute; top: 0.9rem; right: 0.9rem; width: 2.5rem; height: 2.5rem; border-radius: 50%; border: 0; background: var(--ef-acc, #e6ff55); color: #0c1e18; font-size: 1.5rem; line-height: 1; cursor: pointer; opacity: 0; transition: opacity 200ms ease; }
.ef-gzl__zoom.is-shown .ef-gzl__close { opacity: 1; transition-delay: calc(var(--ef-dur) * 0.4); }
.ef-gzl__close:focus-visible { outline: 2px solid #fbf8f1; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .ef-gzl__tile, .ef-gzl__tile:hover { transition: opacity 200ms; transform: none; } .ef-gzl__zbody, .ef-gzl__zoom.is-shown .ef-gzl__zbody { transform: none; transition: opacity 200ms; } .ef-gzl__zoom.is-shown .ef-gzl__close { transition-delay: 0ms; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Grid zoom layout: click a tile and it zooms (FLIP: measure, invert, play) to fill the stage while the other tiles fade.
// One overlay layer is set to full size, transformed back onto the clicked tile, then released to animate to its natural size.
// Esc or the close button reverses it. Reduced motion swaps the zoom for a crossfade. Focus moves to Close and returns to the tile.
// mount(root, { background, accent, columns (2-5), count (4-12), duration (ms), gap (px) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-gzl__wrap");
  var grid = root.querySelector(".ef-gzl__grid");
  var zoom = root.querySelector(".ef-gzl__zoom");
  var zart = root.querySelector(".ef-gzl__zart");
  var zk = root.querySelector(".ef-gzl__zk");
  var zt = root.querySelector(".ef-gzl__zt");
  var zs = root.querySelector(".ef-gzl__zs");
  var closeBtn = root.querySelector(".ef-gzl__close");
  var opts = {}, offs = [], current = null, timer = 0, raf = 0, destroyed = false;
  var DATA = [["Tidal", "Slow water, long light and the quiet minutes before the tide turns."], ["Ember", "Warm studio series built around heat, grain and low light."], ["Moss", "Quiet objects collected from damp stone and old forest floors."], ["Dusk", "A night shift in blue and lime, shot between the last two trains."], ["Arch", "Form study of arches, openings and the shapes between them."], ["Grain", "A print run in halftone dots, one pass per colour."], ["Haze", "Soft light through glass: diffusion, bloom and drifting dust."], ["Loop", "A motion kit that repeats, in stacks of seamless gradients."], ["Fold", "Paper, pleats and the geometry of a single crease."], ["Pulse", "Concentric rings that breathe in time with a slow beat."], ["Drift", "Layered dunes and the wind lines that move across them."], ["Prism", "Hard edges, split colour and a little optical trickery."]];

  // 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-gzl__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 12px, " + d + " 12px 24px)";
    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 "radial-gradient(ellipse 36% 26% at 50% 44%, " + c + " 0 99%, transparent 100%), linear-gradient(190deg, #005236, " + a + ")";
    return "radial-gradient(circle, " + a + " 0 2.5px, transparent 3px) 0 0 / 16px 16px, linear-gradient(145deg, #005236, " + d + ")";
  }
  function clear() { offs.forEach(function (f) { f(); }); offs = []; }
  function stopAnim() { clearTimeout(timer); cancelAnimationFrame(raf); }
  function reset() {
    stopAnim();
    current = null;
    zoom.classList.remove("is-on", "is-shown");
    zoom.style.transition = "none"; zoom.style.transform = ""; zoom.style.opacity = "";
    zoom.setAttribute("aria-hidden", "true");
    grid.classList.remove("is-open");
  }
  function from(tile) {
    var w = wrap.getBoundingClientRect(), r = tile.getBoundingClientRect();
    return "translate(" + (r.left - w.left) + "px," + (r.top - w.top) + "px) scale(" + (r.width / w.width) + "," + (r.height / w.height) + ")";
  }
  function openTile(tile, i) {
    if (current !== null || destroyed) return;
    current = tile;
    zart.style.background = tile.firstChild.style.background;
    zart.textContent = "";
    var pc = tile.firstChild.querySelector(".ef-gzl__pc");
    if (pc) zart.appendChild(pc.cloneNode(true));
    zk.textContent = String(i + 1).padStart(2, "0") + " / " + String(opts.count).padStart(2, "0");
    zt.textContent = DATA[i % DATA.length][0];
    zs.textContent = DATA[i % DATA.length][1];
    zoom.setAttribute("aria-hidden", "false");
    grid.classList.add("is-open");
    zoom.classList.add("is-on");
    if (reduce) {
      zoom.style.transition = "none"; zoom.style.transform = "none"; zoom.style.opacity = "0";
      zoom.getBoundingClientRect();
      raf = requestAnimationFrame(function () { zoom.style.transition = "opacity 200ms"; zoom.style.opacity = "1"; zoom.classList.add("is-shown"); });
    } else {
      zoom.style.transition = "none"; zoom.style.opacity = "1";
      zoom.style.transform = from(tile); // invert: start looking like the tile
      zoom.getBoundingClientRect(); // flush
      raf = requestAnimationFrame(function () {
        zoom.style.transition = "transform " + opts.duration + "ms cubic-bezier(.65,.05,.2,1)";
        zoom.style.transform = "none"; // play
        zoom.classList.add("is-shown");
      });
    }
    closeBtn.focus({ preventScroll: true });
  }
  function close() {
    if (current === null || closing) return;
    var tile = current;
    closing = true;
    zoom.classList.remove("is-shown");
    grid.classList.remove("is-open");
    stopAnim();
    var done = function () { closing = false; reset(); if (!destroyed) tile.focus({ preventScroll: true }); };
    if (reduce) {
      zoom.style.transition = "opacity 200ms"; zoom.style.opacity = "0";
      timer = setTimeout(done, 220);
    } else {
      zoom.style.transition = "transform " + opts.duration + "ms cubic-bezier(.65,.05,.2,1)";
      zoom.style.transform = from(tile);
      timer = setTimeout(done, opts.duration + 40);
    }
  }
  var closing = false;
  function onKey(e) { if (e.key === "Escape" && current) { e.preventDefault(); close(); } }
  function build() {
    clear(); reset(); closing = false;
    grid.textContent = "";
    for (var i = 0; i < opts.count; i++) (function (i) {
      var b = document.createElement("button");
      b.type = "button"; b.className = "ef-gzl__tile";
      b.setAttribute("aria-label", "Open " + DATA[i % DATA.length][0]);
      var bg = document.createElement("span"); bg.className = "ef-gzl__art"; bg.style.background = art(i, opts.accent);
      bg.insertAdjacentHTML("beforeend", piece(i, opts.accent));
      var t = document.createElement("span"); t.className = "ef-gzl__tt"; t.textContent = DATA[i % DATA.length][0];
      b.appendChild(bg); b.appendChild(t);
      var on = function () { openTile(b, i); };
      b.addEventListener("click", on);
      offs.push(function () { b.removeEventListener("click", on); });
      grid.appendChild(b);
    })(i);
    closeBtn.addEventListener("click", close);
    document.addEventListener("keydown", onKey);
    offs.push(function () { closeBtn.removeEventListener("click", close); document.removeEventListener("keydown", onKey); });
  }
  function update(next) {
    var prev = opts;
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", columns: 3, count: 6, duration: 600, gap: 12 }, opts, next || {});
    opts.count = Math.max(4, Math.min(12, Math.round(opts.count)));
    opts.columns = Math.max(2, Math.min(5, Math.round(opts.columns)));
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    wrap.style.setProperty("--ef-cols", String(opts.columns));
    wrap.style.setProperty("--ef-gap", opts.gap + "px");
    wrap.style.setProperty("--ef-dur", opts.duration + "ms");
    if (prev.count !== opts.count || prev.accent !== opts.accent) build();
  }
  update(o);
  return { update: update, destroy: function () { destroyed = true; clear(); stopAnim(); } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","columns":3,"count":6,"duration":600,"gap":12});

How it works

On click the script measures the tile and the stage with getBoundingClientRect (First). A single overlay layer is sized to fill the stage (Last), then given a transform that makes it look exactly like the tile: a translate plus a non-uniform scale (Invert). On the next frame the transform is removed with a CSS transition (Play), so the browser animates the overlay from the tile to full size on the compositor. The title and close button sit in the overlay and fade in after the zoom. The other tiles drop to a low opacity through a class on the grid. Closing runs the same transform in reverse, then resets the overlay and returns focus to the tile.

Accessibility and performance

Tiles are real buttons with labels, so they are reachable and activated from the keyboard. Opening moves focus to the Close button, Esc closes, and focus returns to the tile that opened it. The overlay is aria-hidden while closed. Reduced motion replaces the zoom with a short crossfade. Check the contrast of the title over your own artwork; a darkening scrim sits under the text.

Only transform and opacity animate, and only one element is transformed per zoom. Measurement happens twice per open or close. The non-uniform scale briefly stretches the overlay artwork, which is fine for gradients; with photos, crop the image in a child so it does not distort.

SectionGallery
TechniquesPointer interaction, Reveal and intro
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byGrid Zoom Layout by Manoela Ilic 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.

Clip reveal menuText rows that open a generated pattern behind the word with a clip-path transition on hover, while the word inverts along the edge. Image compare on scrollA before and after slider whose divider is scrubbed by scroll position and can also be dragged or keyboard-operated. Unroll image on scrollA poster that unrolls like a sheet of paper from a spiral roll as you scroll or hover.

FAQ

Why does the overlay scale non-uniformly?

The tile and the stage have different aspect ratios, so the inverted transform needs separate x and y scale to line the overlay up with the tile. The text is a separate layer that fades in afterwards, so it never looks stretched.

Can I use images instead of gradients?

Yes. Set an image as the background of the tile art layer; the overlay copies that background when it opens. Use a large enough source for the full-size view.

Does it work inside a clipped container?

Yes. The overlay is positioned inside the stage and the stage clips it, so the zoom never escapes the container.