Copied!
Full page

Pixel grid page transition

Click a piece and a grid of cells scales in row by row to cover the frame, swaps the view underneath, then scales back out to reveal the new one.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

HeroElementVanilla 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 "Pixel grid page transition" for my site using the Enrichr Library element at https://enrichr.dev/elements/pixel-grid-transition.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/pixel-grid-transition.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
- rows: 8
- cols: 14
- duration: 420
- jitter: 5

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/pixel-grid-transition.json.

Code

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

HTML

<div class="ef-pgt">
  <svg width="0" height="0" aria-hidden="true" focusable="false" style="position:absolute">
    <defs>
      <symbol id="ef-pgt-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="ef-pgt-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="ef-pgt-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-pgt__stage">
    <div class="ef-pgt__intro">
      <p class="ef-pgt__kicker">Pick a piece</p>
      <div class="ef-pgt__tiles">
        <button type="button" class="ef-pgt__tile" data-i="0"><svg viewBox="0 0 100 100" aria-hidden="true" focusable="false"><use width="100" height="100" href="#ef-pgt-p1"></use></svg><span>Corner</span></button>
        <button type="button" class="ef-pgt__tile" data-i="1"><svg viewBox="0 0 100 100" aria-hidden="true" focusable="false"><use width="100" height="100" href="#ef-pgt-p4"></use></svg><span>Bridge</span></button>
        <button type="button" class="ef-pgt__tile" data-i="2"><svg viewBox="0 0 100 100" aria-hidden="true" focusable="false"><use width="100" height="100" href="#ef-pgt-p8"></use></svg><span>Tower</span></button>
      </div>
    </div>
    <section class="ef-pgt__panel" data-i="0" aria-label="Corner">
      <div class="ef-pgt__art"><svg viewBox="0 0 100 100" aria-hidden="true" focusable="false"><use width="100" height="100" href="#ef-pgt-p1"></use></svg></div>
      <h3 class="ef-pgt__h">Corner</h3>
      <p class="ef-pgt__p">Every build starts by finding the edge.</p>
      <button type="button" class="ef-pgt__back">Back</button>
    </section>
    <section class="ef-pgt__panel" data-i="1" aria-label="Bridge">
      <div class="ef-pgt__art"><svg viewBox="0 0 100 100" aria-hidden="true" focusable="false"><use width="100" height="100" href="#ef-pgt-p4"></use></svg></div>
      <h3 class="ef-pgt__h">Bridge</h3>
      <p class="ef-pgt__p">The long piece that joins two sections together.</p>
      <button type="button" class="ef-pgt__back">Back</button>
    </section>
    <section class="ef-pgt__panel" data-i="2" aria-label="Tower">
      <div class="ef-pgt__art"><svg viewBox="0 0 100 100" aria-hidden="true" focusable="false"><use width="100" height="100" href="#ef-pgt-p8"></use></svg></div>
      <h3 class="ef-pgt__h">Tower</h3>
      <p class="ef-pgt__p">Tall pieces hold the structure upright.</p>
      <button type="button" class="ef-pgt__back">Back</button>
    </section>
    <div class="ef-pgt__overlay" aria-hidden="true"></div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
/* Adapted from https://github.com/codrops/PixelTransition (MIT) — Copyright (c) 2009 - 2023 [Codrops](https://tympanus.net/codrops) */
.ef-pgt { width: 100%; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-pgt__stage { position: relative; box-sizing: border-box; width: 100%; height: 22rem; border-radius: 1rem; overflow: hidden; background: #0c1e18; color: #fbf8f1; isolation: isolate; }
.ef-pgt__intro { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1.2rem; padding: 1.2rem; }
.ef-pgt__intro.is-closed { visibility: hidden; }
.ef-pgt__kicker { margin: 0; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; opacity: 0.75; }
.ef-pgt__tiles { display: flex; gap: 0.9rem; align-items: stretch; justify-content: center; width: 100%; }
.ef-pgt__tile { appearance: none; border: 0; cursor: pointer; flex: 1 1 0; max-width: 9rem; display: flex; flex-direction: column; align-items: center; gap: 0.6rem; padding: 0.9rem 0.6rem 0.7rem; border-radius: 0.9rem; background: #005236; color: #c6f26b; font: inherit; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.04em; transition: background 200ms, transform 200ms; }
.ef-pgt__tile svg { width: 100%; height: 4.4rem; display: block; }
.ef-pgt__tile span { color: #fbf8f1; }
.ef-pgt__tile:hover { background: #0a6b46; transform: translateY(-3px); }
.ef-pgt__tile:focus-visible, .ef-pgt__back:focus-visible { outline: 2px solid var(--ef-acc, #e6ff55); outline-offset: 3px; }
.ef-pgt__panel { position: absolute; inset: 0; display: none; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 0.5rem; padding: 1.4rem; background: #005236; visibility: hidden; }
.ef-pgt__panel.is-open { display: flex; visibility: visible; }
.ef-pgt__art { width: 7rem; height: 7rem; color: #e6ff55; }
.ef-pgt__art svg { width: 100%; height: 100%; display: block; }
.ef-pgt__h { margin: 0.4rem 0 0; font-size: 1.7rem; font-weight: 800; letter-spacing: -0.02em; }
.ef-pgt__p { margin: 0; max-width: 20rem; font-size: 0.9rem; opacity: 0.85; }
.ef-pgt__back { appearance: none; cursor: pointer; margin-top: 0.9rem; padding: 0.55rem 1.3rem; border-radius: 999px; border: 1.5px solid #fbf8f1; background: transparent; color: #fbf8f1; font: inherit; font-size: 0.82rem; font-weight: 700; letter-spacing: 0.06em; transition: background 200ms, color 200ms; }
.ef-pgt__back:hover { background: #fbf8f1; color: #0c1e18; }
.ef-pgt__overlay { position: absolute; inset: 0; z-index: 5; display: grid; pointer-events: none; }
.ef-pgt__cell { background: var(--ef-cell, #e6ff55); opacity: 0; transform: scale(0); will-change: transform, opacity; }
@media (prefers-reduced-motion: reduce) {
  .ef-pgt__tile { transition: background 200ms; }
  .ef-pgt__tile:hover { transform: none; }
  .ef-pgt__panel.is-open { animation: ef-pgt-fade 200ms linear; }
}
@keyframes ef-pgt-fade { from { opacity: 0; } to { opacity: 1; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Adapted from https://github.com/codrops/PixelTransition (MIT) — Copyright (c) 2009 - 2023 [Codrops](https://tympanus.net/codrops)
// Pixel grid transition: clicking a piece covers the frame with a grid of cells that scale in row by row (with random jitter), swaps the
// view underneath, then scales the cells back out. Cells are driven by one rAF loop; the fade and slide of the views uses the Web
// Animations API. Reduced motion skips the cells and swaps the view straight away. Buttons are native, Escape goes back, and focus
// moves to the new view's button so keyboard users never get lost.
// mount(root, { accent, rows (4-14), cols (6-20), duration (ms), jitter (0-8 rows of random delay) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var stage = root.querySelector(".ef-pgt__stage");
  var intro = root.querySelector(".ef-pgt__intro");
  var overlay = root.querySelector(".ef-pgt__overlay");
  var tiles = [].slice.call(root.querySelectorAll(".ef-pgt__tile"));
  var panels = [].slice.call(root.querySelectorAll(".ef-pgt__panel"));
  var opts = {}, cells = [], raf = 0, busy = false, view = -1, anims = [], offs = [], dead = false, token = 0;

  function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
  function cubicInOut(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
  function quadOut(t) { return 1 - (1 - t) * (1 - t); }

  function buildCells() {
    overlay.textContent = "";
    cells = [];
    overlay.style.gridTemplateColumns = "repeat(" + opts.cols + ", 1fr)";
    overlay.style.gridTemplateRows = "repeat(" + opts.rows + ", 1fr)";
    for (var r = 0; r < opts.rows; r++) for (var c = 0; c < opts.cols; c++) {
      var el = document.createElement("div");
      el.className = "ef-pgt__cell";
      overlay.appendChild(el);
      cells.push({ el: el, row: r });
    }
  }

  // One staggered pass over every cell. kind "in": scale 0 -> 1.01 (opacity 0 -> 1). kind "out": scale 1.01 -> 0.
  function run(kind, origin, reverse) {
    var my = ++token;
    return new Promise(function (resolve) {
      var dur = opts.duration, stag = dur * 0.075, delays = [], maxD = 0;
      for (var i = 0; i < cells.length; i++) {
        var row = reverse ? opts.rows - 1 - cells[i].row : cells[i].row;
        var d = stag * (row + Math.random() * opts.jitter);
        delays.push(d);
        if (d > maxD) maxD = d;
        cells[i].el.style.transformOrigin = origin;
      }
      var t0 = performance.now();
      function step(now) {
        raf = 0;
        if (dead || my !== token) return;
        var done = true;
        for (var j = 0; j < cells.length; j++) {
          var p = clamp((now - t0 - delays[j]) / dur, 0, 1);
          if (p < 1) done = false;
          var s, op;
          if (kind === "in") { var e = cubicInOut(p); s = 1.01 * e; op = e; }
          else { var q = quadOut(p); s = 1.01 * (1 - q); op = 1 - q; }
          var st = cells[j].el.style;
          st.opacity = op.toFixed(3);
          st.transform = "scale(" + s.toFixed(3) + ")";
        }
        if (done) resolve(); else raf = requestAnimationFrame(step);
      }
      raf = requestAnimationFrame(step);
    });
  }

  function anim(el, frames, ms, ease) {
    if (!el.animate) return null;
    var a = el.animate(frames, { duration: ms, easing: ease, fill: "both" });
    anims.push(a);
    return a;
  }
  function cancelAnims() { anims.forEach(function (a) { try { a.cancel(); } catch (e) {} }); anims = []; }

  function setView(i) {
    view = i;
    intro.classList.toggle("is-closed", i >= 0);
    panels.forEach(function (p, k) { p.classList.toggle("is-open", k === i); });
  }
  function focusIn(i) {
    var el = i >= 0 ? panels[i].querySelector(".ef-pgt__back") : tiles[lastTile] ;
    if (el && el.focus) el.focus({ preventScroll: true });
  }
  var lastTile = 0;

  function go(i) {
    if (busy || dead) return;
    var leaving = view;
    if (i >= 0) lastTile = i;
    if (reduce) { setView(i); focusIn(i); return; }
    busy = true;
    var opening = i >= 0;
    var out = opening ? intro : panels[leaving].querySelector(".ef-pgt__art");
    anim(out, [{ opacity: 1, transform: "translateY(0)" }, { opacity: 0, transform: "translateY(" + (opening ? 15 : -15) + "%)" }], 800, "cubic-bezier(.65,0,.35,1)");
    run("in", opening ? "50% 0%" : "50% 100%", !opening).then(function () {
      if (dead) return;
      cancelAnims();
      setView(i);
      focusIn(i);
      var inn = opening ? panels[i].querySelector(".ef-pgt__art") : intro;
      anim(inn, [{ opacity: 0, transform: "translateY(" + (opening ? -25 : 15) + "%)" }, { opacity: 1, transform: "translateY(0)" }], 800, "cubic-bezier(.22,1,.36,1)");
      return run("out", opening ? "50% 100%" : "50% 0%", !opening);
    }).then(function () {
      if (dead) return;
      busy = false;
      for (var k = 0; k < cells.length; k++) { cells[k].el.style.opacity = "0"; cells[k].el.style.transform = "scale(0)"; }
    });
  }

  function reset() {
    token++;
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
    cancelAnims();
    busy = false;
    setView(-1);
  }

  tiles.forEach(function (b, i) {
    var f = function () { go(i); };
    b.addEventListener("click", f);
    offs.push(function () { b.removeEventListener("click", f); });
  });
  panels.forEach(function (p) {
    var b = p.querySelector(".ef-pgt__back");
    var f = function () { go(-1); };
    b.addEventListener("click", f);
    offs.push(function () { b.removeEventListener("click", f); });
  });
  function key(e) { if (e.key === "Escape" && view >= 0 && !busy) { e.preventDefault(); go(-1); } }
  stage.addEventListener("keydown", key);
  offs.push(function () { stage.removeEventListener("keydown", key); });

  function update(next) {
    var prev = opts;
    opts = Object.assign({ accent: "#e6ff55", rows: 8, cols: 14, duration: 420, jitter: 5 }, opts, next || {});
    opts.rows = clamp(Math.round(opts.rows), 4, 14);
    opts.cols = clamp(Math.round(opts.cols), 6, 20);
    opts.duration = clamp(Number(opts.duration), 150, 1200);
    opts.jitter = clamp(Number(opts.jitter), 0, 8);
    stage.style.setProperty("--ef-cell", opts.accent);
    stage.style.setProperty("--ef-acc", opts.accent);
    if (prev.rows !== opts.rows || prev.cols !== opts.cols) {
      if (busy) reset();
      buildCells();
    }
  }
  function destroy() {
    dead = true;
    token++;
    if (raf) cancelAnimationFrame(raf);
    raf = 0;
    cancelAnims();
    offs.forEach(function (f) { f(); });
    offs = [];
  }
  update(o);
  return { update: update, destroy: destroy };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","rows":8,"cols":14,"duration":420,"jitter":5});

How it works

A vanilla port with no GSAP. An overlay holds a CSS grid of cells. Each pass gives every cell a delay of its row number plus a random offset, and one rAF loop eases each cell's scale and opacity between 0 and 1.01. The cover pass grows cells from the top edge; once every cell is in, the script swaps which view is visible and runs the exit pass with cells shrinking toward the bottom edge. The fade and slide of the views uses the Web Animations API. Going back runs the same sequence mirrored.

Accessibility and performance

Pieces and the back button are native buttons, Escape also goes back, and focus moves to the new view's button once the cover is complete. Clicks are ignored while a transition is running. Under reduced motion there are no cells: the view swaps immediately with a brief fade. The hidden view is display none, so it is not reachable by keyboard or screen reader.

About one hundred cells animate scale and opacity only, from a single rAF loop that stops when the pass finishes. Fewer, larger cells are cheaper on weak devices. The cells are removed from interaction with pointer-events none.

SectionFull page
TechniquesReveal and intro, Stagger
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byPixel 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

How do I use my own pages?

Put each destination in a panel like the ones in the markup. The script only toggles an is-open class, so panels can hold any content.

How do I change the direction of the wipe?

The origin passed to each pass decides it. Change 50% 0% and 50% 100% in go() to 0% 50% and 100% 50% for a sideways wipe, and base the delay on the column instead of the row.

Does it block scrolling or clicks while running?

Clicks on the pieces and back button are ignored until the transition ends. Nothing is attached to the window, so it is safe inside overflow hidden containers.