Copied!
Gallery

Image to grid reveal

A large hero image shrinks and glides into its slot in a scattered grid while the other tiles fly in from the centre outwards, with sliding labels and a colour shift. Puzzle-piece art, no images.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

GalleryElementVanilla 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 to grid reveal" for my site using the Enrichr Library element at https://enrichr.dev/elements/image-to-grid-reveal.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/image-to-grid-reveal.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:
- title: Siobhan O'dwaya
- accent: #e6ff55
- duration: 1.4
- spread: 120
- art: puzzle

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-to-grid-reveal.json.

Code

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

HTML

<div class="ef-itg">
  <svg class="ef-itg__defs" width="0" height="0" aria-hidden="true" focusable="false"><defs>
<symbol id="efitg-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="efitg-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="efitg-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-itg__stage"></div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
/* Adapted from https://github.com/codrops/ImageToGridTransition (MIT) — Copyright (c) 2009 - 2021 Codrops */
.ef-itg { width: 100%; }
.ef-itg__defs { position: absolute; width: 0; height: 0; overflow: hidden; }
.ef-itg__stage { --bg: #005236; --fg: #fbf8f1; --acc: #e6ff55; position: relative; box-sizing: border-box; width: 100%; height: 24rem; overflow: hidden; border-radius: 1rem; background: var(--bg); color: var(--fg); font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-itg__tile { position: absolute; display: block; will-change: transform, opacity; }
.ef-itg__img, .ef-itg__hero { position: relative; overflow: hidden; border-radius: 0.5rem; }
.ef-itg__tile .ef-itg__img { width: 100%; height: 100%; }
.ef-itg__hero { position: absolute; z-index: 2; border-radius: 0.7rem; will-change: left, top, width, height; }
.ef-itg__piece { position: absolute; left: 50%; top: 50%; width: 62%; height: 62%; transform: translate(-50%, -50%) rotate(var(--rot, 0deg)); opacity: 0.9; display: block; }
.ef-itg__hero .ef-itg__piece { width: 56%; height: 56%; color: var(--acc); opacity: 0.95; }
.ef-itg__img::after, .ef-itg__hero::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(12, 30, 24, 0) 55%, rgba(12, 30, 24, 0.35)); pointer-events: none; }
.ef-itg__lbl { position: absolute; left: 0; right: 0; top: 100%; margin-top: 0.3rem; overflow: hidden; font-size: 0.62rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; line-height: 1.25; }
.ef-itg__lbl > span { display: block; will-change: transform; }
.ef-itg__big { position: absolute; z-index: 3; left: 32%; top: 12%; margin: 0; color: #fbf8f1; font-size: 2rem; font-weight: 800; line-height: 0.95; letter-spacing: -0.03em; text-transform: uppercase; pointer-events: none; }
.ef-itg__big > span { display: block; will-change: transform, opacity; }
.ef-itg__small { position: absolute; z-index: 1; margin: 0; font-size: 0.8rem; font-weight: 800; line-height: 1; letter-spacing: -0.01em; text-transform: uppercase; pointer-events: none; }
.ef-itg__small > span { display: block; margin-bottom: 0.15rem; will-change: transform, opacity; }
.ef-itg__btn { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.ef-itg__enter { position: absolute; z-index: 4; left: 50%; bottom: 11%; margin-left: -2.2rem; width: 4.4rem; height: 4.4rem; border-radius: 50%; display: grid; place-items: center; color: #fbf8f1; font-size: 0.68rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; box-shadow: inset 0 0 0 1.5px rgba(251, 248, 241, 0.75); transition: background 250ms, color 250ms; }
.ef-itg__enter:hover { background: var(--acc); color: #0c1e18; box-shadow: inset 0 0 0 1.5px var(--acc); }
.ef-itg__back { position: absolute; z-index: 4; width: 3rem; height: 1.6rem; display: grid; place-items: center; border-radius: 0.4rem; }
.ef-itg__back svg { width: 100%; height: 0.7rem; overflow: visible; fill: none; stroke: currentColor; stroke-width: 1.5; }
.ef-itg__btn:focus-visible { outline: 2px solid var(--acc); outline-offset: 3px; }
.ef-itg__back:focus-visible { outline-color: #0a6b46; }
.ef-itg__stage[data-open="0"] .ef-itg__back, .ef-itg__stage[data-open="0"] .ef-itg__tile { pointer-events: none; }
@media (prefers-reduced-motion: reduce) { .ef-itg__enter { transition: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Adapted from https://github.com/codrops/ImageToGridTransition (MIT) — Copyright (c) 2009 - 2021 Codrops
// Image to grid reveal: one big hero image shrinks and glides into its slot in a scattered grid while the other tiles fly in from the centre outwards.
// Vanilla port, no GSAP: one eased progress value (0 closed, 1 open) is tweened with requestAnimationFrame, and every layer is a pure function of it.
// The hero image is moved by interpolating its first and last rectangles (a hand-rolled FLIP). Reduced motion swaps instantly.
// mount(root, { title, accent, duration (s), spread (px), art ("puzzle"|"gradient") })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var stage = root.querySelector(".ef-itg__stage");
  var opts = {}, raf = 0, prog = 0, target = 0, last = 0, built = false;
  var TILES = [
    [5, 8, 20, 28, "Carrutha Method"], [5, 46, 15, 40, "Maria Coluga"], [24, 56, 19, 34, "Gabeta Quest"], [30, 6, 14, 40, "Lithera Britta"],
    [71, 8, 24, 26, "Manata Poly"], [71, 40, 12, 30, "Ghata Manabra"], [86, 40, 10, 34, "Hollis Vane"], [47, 72, 22, 20, "Tessa Orrin"]
  ];
  var HERO = [30, 8, 40, 84], SLOT = [49, 24, 17, 40];
  var GRADS = [
    "linear-gradient(160deg, #0a6b46, #0c1e18)", "linear-gradient(200deg, #c6f26b, #2f7d5b)", "radial-gradient(circle at 30% 30%, #e6ff55, #005236 70%)", "linear-gradient(135deg, #0c1e18, #2f7d5b)",
    "linear-gradient(180deg, #fbf8f1, #c6f26b)", "conic-gradient(from 200deg at 40% 60%, #005236, #c6f26b, #0a6b46)", "linear-gradient(150deg, #2f7d5b, #005236)", "radial-gradient(circle at 70% 70%, #c6f26b, #0c1e18 75%)"
  ];
  var PIECES = ["p1", "p4", "p8", "p1", "p8", "p4", "p1", "p4"];
  var els = { tiles: [], lbls: [] };

  function clamp(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
  function expo(p) { return p <= 0 ? 0 : p >= 1 ? 1 : p < 0.5 ? Math.pow(2, 20 * p - 10) / 2 : (2 - Math.pow(2, -20 * p + 10)) / 2; }
  function expoOut(p) { return p >= 1 ? 1 : 1 - Math.pow(2, -10 * p); }
  function lerp(a, b, t) { return a + (b - a) * t; }
  function hex(c) { return [parseInt(c.substr(1, 2), 16), parseInt(c.substr(3, 2), 16), parseInt(c.substr(5, 2), 16)]; }
  function mix(a, b, t) { var x = hex(a), y = hex(b); return "rgb(" + Math.round(lerp(x[0], y[0], t)) + "," + Math.round(lerp(x[1], y[1], t)) + "," + Math.round(lerp(x[2], y[2], t)) + ")"; }
  function svgPiece(id, cls) {
    var s = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    s.setAttribute("class", cls); s.setAttribute("aria-hidden", "true"); s.setAttribute("focusable", "false");
    var u = document.createElementNS("http://www.w3.org/2000/svg", "use");
    u.setAttribute("href", "#efitg-" + id);
    s.appendChild(u);
    return s;
  }
  function el(tag, cls, txt) { var e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }

  function build() {
    stage.textContent = "";
    els = { tiles: [], lbls: [] };
    var cx = 50, cy = 50;
    // grid tiles (decorative)
    TILES.forEach(function (t, i) {
      var tile = el("div", "ef-itg__tile");
      tile.setAttribute("aria-hidden", "true");
      tile.style.cssText = "left:" + t[0] + "%;top:" + t[1] + "%;width:" + t[2] + "%;height:" + t[3] + "%";
      var img = el("div", "ef-itg__img");
      img.style.background = GRADS[i % GRADS.length];
      if (opts.art === "puzzle") {
        var pc = svgPiece(PIECES[i], "ef-itg__piece");
        pc.style.color = i % 3 === 0 ? opts.accent : i % 3 === 1 ? "#fbf8f1" : "#c6f26b";
        pc.style.setProperty("--rot", (i * 37 % 40 - 20) + "deg");
        img.appendChild(pc);
      }
      var lbl = el("div", "ef-itg__lbl"); lbl.appendChild(el("span", "", t[4]));
      tile.appendChild(img); tile.appendChild(lbl);
      var mx = t[0] + t[2] / 2 - cx, my = t[1] + t[3] / 2 - cy, d = Math.hypot(mx, my) || 1;
      tile._v = { ux: mx / d, uy: my / d, delay: clamp(d / 70) * 0.3 };
      stage.appendChild(tile);
      els.tiles.push(tile); els.lbls.push(lbl.firstChild);
    });
    // slot label
    var slot = el("div", "ef-itg__tile"); slot.setAttribute("aria-hidden", "true");
    slot.style.cssText = "left:" + SLOT[0] + "%;top:" + SLOT[1] + "%;width:" + SLOT[2] + "%;height:" + SLOT[3] + "%";
    var sl = el("div", "ef-itg__lbl"); sl.appendChild(el("span", "", "Featured"));
    slot.appendChild(sl); stage.appendChild(slot); els.slot = sl.firstChild;
    // hero
    var hero = el("div", "ef-itg__hero");
    hero.style.background = "radial-gradient(circle at 70% 28%, rgba(230,255,85,0.5), transparent 45%), linear-gradient(165deg, #2f7d5b, #0c1e18)";
    hero.appendChild(svgPiece("p8", "ef-itg__piece"));
    stage.appendChild(hero); els.hero = hero;
    // titles
    var words = String(opts.title || "").trim().split(/\s+/);
    var a = words.shift() || "", b = words.join(" ");
    var big = el("h3", "ef-itg__big"); big.appendChild(el("span", "", a)); big.appendChild(el("span", "", b));
    var sm = el("h3", "ef-itg__small"); sm.style.cssText = "left:" + (SLOT[0]) + "%;top:5%"; sm.appendChild(el("span", "", a)); sm.appendChild(el("span", "", b));
    stage.appendChild(big); stage.appendChild(sm);
    els.big = big.children; els.sm = sm.children;
    // controls
    var enter = el("button", "ef-itg__btn ef-itg__enter", "Enter");
    enter.type = "button"; enter.setAttribute("aria-label", "Enter: reveal the grid");
    var back = el("button", "ef-itg__btn ef-itg__back");
    back.type = "button"; back.setAttribute("aria-label", "Back to the large image");
    back.style.cssText = "left:" + (SLOT[0] + SLOT[2] + 4) + "%;top:" + (SLOT[1] + SLOT[3] + 18) + "%";
    back.innerHTML = '<svg viewBox="0 0 50 9" aria-hidden="true"><path d="m0 4.5 5-3m-5 3 5 3m45-3h-50"/></svg>';
    stage.appendChild(enter); stage.appendChild(back);
    els.enter = enter; els.back = back;
    built = true;
    render(prog);
  }

  function rect(r) { var w = stage.clientWidth, h = stage.clientHeight; return [r[0] / 100 * w, r[1] / 100 * h, r[2] / 100 * w, r[3] / 100 * h]; }

  function render(p) {
    if (!built) return;
    var e = expo(p), a = rect(HERO), b = rect(SLOT), hs = els.hero.style;
    hs.left = lerp(a[0], b[0], e) + "px"; hs.top = lerp(a[1], b[1], e) + "px";
    hs.width = lerp(a[2], b[2], e) + "px"; hs.height = lerp(a[3], b[3], e) + "px";
    stage.style.setProperty("--bg", mix("#005236", "#fbf8f1", e));
    stage.style.setProperty("--fg", mix("#fbf8f1", "#0c1e18", e));
    stage.style.setProperty("--acc", opts.accent);
    stage.dataset.open = p > 0.5 ? "1" : "0";
    els.tiles.forEach(function (tile, i) {
      var v = tile._v, lp = clamp((p - 0.1 - v.delay) / 0.55), mv = 1 - expoOut(lp);
      tile.style.opacity = lp > 0 ? clamp(lp * 5) : 0;
      tile.style.transform = "translate(" + (v.ux * opts.spread * mv).toFixed(1) + "px," + (v.uy * opts.spread * mv).toFixed(1) + "px)";
      var tl = expo(clamp((p - v.delay * 0.5) / 0.7));
      els.lbls[i].style.transform = "translateY(" + ((1 - tl) * 110).toFixed(1) + "%)";
    });
    els.slot.style.transform = "translateY(" + ((1 - expo(clamp(p / 0.8))) * 110).toFixed(1) + "%)";
    var out = expo(clamp(p / 0.6)), inn = expo(clamp((p - 0.1) / 0.7));
    for (var k = 0; k < 2; k++) {
      els.big[k].style.transform = "translateY(" + ((k ? 1 : -1) * out * 110) + "%)";
      els.big[k].style.opacity = 1 - out;
      els.sm[k].style.transform = "translateX(" + ((k ? 1 : -1) * (1 - inn) * 50) + "%)";
      els.sm[k].style.opacity = inn;
    }
    els.enter.style.opacity = 1 - clamp(p / 0.4);
    els.back.style.opacity = clamp((p - 0.3) / 0.5);
    var open = p > 0.5;
    els.enter.tabIndex = open ? -1 : 0; els.back.tabIndex = open ? 0 : -1;
    els.enter.style.pointerEvents = open ? "none" : "auto";
    els.back.style.pointerEvents = open ? "auto" : "none";
  }

  function frame(now) {
    var dt = Math.min(0.05, (now - last) / 1000); last = now;
    var step = dt / Math.max(0.05, opts.duration);
    if (prog < target) prog = Math.min(target, prog + step); else prog = Math.max(target, prog - step);
    render(prog);
    raf = prog === target ? 0 : requestAnimationFrame(frame);
    if (!raf) settle();
  }
  function settle() { if (target === 1) els.back.focus({ preventScroll: true }); else if (stage.contains(document.activeElement) || document.activeElement === document.body) els.enter.focus({ preventScroll: true }); }
  function go(t) {
    target = t;
    if (reduce) { prog = t; render(prog); settle(); return; }
    if (!raf) { last = performance.now(); raf = requestAnimationFrame(frame); }
  }
  function onClick(ev) {
    if (ev.target.closest(".ef-itg__enter")) go(1);
    else if (ev.target.closest(".ef-itg__back")) go(0);
  }
  function onKey(ev) { if (ev.key === "Escape" && target === 1) go(0); }
  var ro = typeof ResizeObserver === "function" ? new ResizeObserver(function () { render(prog); }) : null;
  if (ro) ro.observe(stage);
  stage.addEventListener("click", onClick);
  stage.addEventListener("keydown", onKey);

  function update(next) {
    var prev = opts;
    opts = Object.assign({ title: "Siobhan O'dwaya", accent: "#e6ff55", duration: 1.4, spread: 120, art: "puzzle" }, opts, next || {});
    opts.duration = Math.max(0.2, Math.min(4, +opts.duration || 1.4));
    opts.spread = Math.max(0, Math.min(400, +opts.spread || 0));
    if (!built || prev.title !== opts.title || prev.accent !== opts.accent || prev.art !== opts.art) build(); else render(prog);
  }
  update(o);
  return {
    update: update,
    destroy: function () {
      if (raf) cancelAnimationFrame(raf); raf = 0;
      if (ro) ro.disconnect();
      stage.removeEventListener("click", onClick);
      stage.removeEventListener("keydown", onKey);
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"title":"Siobhan O'dwaya","accent":"#e6ff55","duration":1.4,"spread":120,"art":"puzzle"});

How it works

This is a vanilla port with no GSAP or Flip plugin. One progress value from 0 (closed) to 1 (open) is tweened with requestAnimationFrame, and every layer is a pure function of it. The hero image is moved by interpolating between its first rectangle (centre of the box) and its last rectangle (its slot in the grid), which is a hand-written FLIP. Each tile gets a delay based on its distance from the centre and flies in along the vector pointing away from it; labels slide up out of an overflow clip; the background and text colours are mixed from the same progress value. Because everything reads one number, reversing mid-flight just works.

Accessibility and performance

Enter and Back are real buttons with labels, focus moves to Back when the grid opens and returns to Enter when it closes, and Escape closes the grid. The decorative tiles are hidden from assistive tech. With reduced motion the hero and grid swap instantly with no movement.

Tiles animate transform and opacity only. The hero changes left, top, width and height each frame, which is fine for one element but would be worth switching to a transform-based FLIP if the hero held heavy content. The loop stops as soon as the value settles.

SectionGallery
TechniquesReveal and intro, Stagger
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byImage to Grid Transition 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.

Rapid layers introA stack of full-bleed colour layers wipes in quickly one after another, then wipes away to reveal a headline, with a replay button. Island menuA round menu button that stretches into a wide pill, spins its logo in, turns its bars into a cross and pops a panel of links out of the corner. Staggered grid revealA grid of tiles that flip up one after another in random, reading, centre-out or edges-in order, then spin their glyphs into place.

FAQ

Can I use real photos instead of the art?

Yes. Set a background-image on each .ef-itg__img and on the hero in build(), and drop the puzzle-piece SVGs.

Why not use a transform for the hero move?

Interpolating the rectangle avoids scale distortion of the hero content, so text or images inside it never stretch. For a plain image, a transform FLIP is a good swap.

How do I change where the tiles sit?

The TILES array holds each tile as percentages of the box (left, top, width, height), and SLOT is the hero's final slot.