Copied!
Gallery

Staggered grid reveal

A grid of tiles that flip up one after another in random, reading, centre-out or edges-in order, then spin their glyphs into place.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

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

Read the machine-readable spec and source at https://enrichr.dev/elements/staggered-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:
- count: 12
- columns: 4
- order: random
- stagger: 70
- words: Motion, Form, Direction, Rhythm
- accent: #e6ff55
- background: #0c1e18

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/staggered-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-sgr">
  <div class="ef-sgr__bar">
    <p class="ef-sgr__cap">One timeline, many orders.</p>
    <button type="button" class="ef-sgr__btn">Replay reveal</button>
  </div>
  <div class="ef-sgr__grid"></div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-sgr { --ef-sgr-bg: #0c1e18; --ef-sgr-acc: #e6ff55; --ef-sgr-ink: #fbf8f1; --ef-sgr-cols: 4; width: 100%; box-sizing: border-box; padding: 1.2rem; border-radius: 1rem; background: var(--ef-sgr-bg); color: var(--ef-sgr-ink); font-family: "Helvetica Neue", Arial, "Segoe UI", system-ui, sans-serif; }
.ef-sgr__bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.8rem; margin: 0 0 1rem; }
.ef-sgr__cap { margin: 0; font-size: 0.95rem; opacity: 0.8; }
.ef-sgr__btn { padding: 0.7rem 1.2rem; border: 1px solid color-mix(in srgb, var(--ef-sgr-ink) 40%, transparent); border-radius: 999px; background: transparent; color: inherit; font: inherit; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: background 200ms, color 200ms; }
.ef-sgr__btn:hover { background: var(--ef-sgr-acc); color: var(--ef-sgr-bg); border-color: var(--ef-sgr-acc); }
.ef-sgr__btn:focus-visible { outline: 3px solid var(--ef-sgr-acc); outline-offset: 3px; }
.ef-sgr__grid { display: grid; grid-template-columns: repeat(var(--ef-sgr-cols), minmax(0, 1fr)); gap: 0.75rem; perspective: 900px; }
.ef-sgr__tile { position: relative; aspect-ratio: 1; box-sizing: border-box; padding: 0.8rem; border-radius: 0.75rem; display: flex; flex-direction: column; justify-content: space-between; overflow: hidden; background: color-mix(in srgb, var(--ef-sgr-bg) 60%, #2c6b4a); color: var(--ef-sgr-ink); transform-origin: 50% 100%; backface-visibility: hidden; container-type: inline-size; }
.ef-sgr__tile:nth-child(4n+2) { background: var(--ef-sgr-acc); color: var(--ef-sgr-bg); }
.ef-sgr__tile:nth-child(4n+3) { background: var(--ef-sgr-ink); color: var(--ef-sgr-bg); }
.ef-sgr__tile:nth-child(4n) { background: color-mix(in srgb, var(--ef-sgr-acc) 30%, #3d6b52); }
.ef-sgr__n { font: 600 0.62rem/1 ui-monospace, "SFMono-Regular", Menlo, monospace; letter-spacing: 0.1em; opacity: 0.75; }
.ef-sgr__g { display: block; width: 46%; margin: 0 auto; height: auto; }
.ef-sgr__w { font: 700 clamp(0.55rem, 9cqi, 0.8rem)/1 ui-monospace, "SFMono-Regular", Menlo, monospace; letter-spacing: 0.14em; text-transform: uppercase; }
.ef-sgr.is-pending .ef-sgr__tile { opacity: 0; }
@media (max-width: 520px) { .ef-sgr__grid { grid-template-columns: repeat(min(var(--ef-sgr-cols), 3), minmax(0, 1fr)); } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Staggered grid reveal: a grid of tiles that flip up out of the floor one after another, in random, reading, centre-out or
// edges-in order, and then spin their glyphs into place. It plays once when the grid scrolls into view and again from the
// Replay button. Built with the Web Animations API; fill "backwards" keeps each tile hidden only during its own delay,
// so once the animation ends the tiles are plain, unanimated elements again.
// mount(root, { count (4-16 tiles), columns (2-6), order ("random", "start", "center", "edges"), stagger (ms between tiles),
//               words (comma separated), accent, background })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-sgr");
  var grid = root.querySelector(".ef-sgr__grid");
  var btn = root.querySelector(".ef-sgr__btn");
  var opts = {}, tiles = [], anims = [], io = null, played = false;
  var NS = "http://www.w3.org/2000/svg";
  var GLYPHS = [
    '<path d="M50 8V92M8 50H92M20 20L80 80M80 20L20 80" stroke-width="11" stroke-linecap="round"/>',
    '<circle cx="50" cy="50" r="40" stroke-width="8"/><path d="M50 10a40 40 0 0 1 0 80z" stroke="none" fill="currentColor"/>',
    '<path d="M22 78L78 22M36 22h42v42" stroke-width="11" stroke-linecap="round" stroke-linejoin="round"/>',
    '<circle cx="50" cy="50" r="40" stroke-width="7"/><circle cx="50" cy="50" r="20" stroke-width="7"/>',
    '<path d="M50 6L94 50L50 94L6 50Z" stroke-width="8" stroke-linejoin="round"/>',
    '<path d="M10 70Q30 30 50 70T90 70" stroke-width="10" stroke-linecap="round"/>'
  ];

  function stop() { anims.forEach(function (a) { try { a.cancel(); } catch (e) {} }); anims = []; }
  function build() {
    stop();
    grid.textContent = "";
    tiles = [];
    var words = String(opts.words).split(",").map(function (w) { return w.trim(); }).filter(Boolean);
    if (!words.length) words = ["Tile"];
    for (var i = 0; i < opts.count; i++) {
      var t = document.createElement("article"); t.className = "ef-sgr__tile";
      var n = document.createElement("span"); n.className = "ef-sgr__n"; n.textContent = String(i + 1).padStart(2, "0");
      var g = document.createElementNS(NS, "svg"); g.setAttribute("viewBox", "0 0 100 100"); g.setAttribute("class", "ef-sgr__g");
      g.setAttribute("aria-hidden", "true"); g.setAttribute("focusable", "false"); g.setAttribute("fill", "none"); g.setAttribute("stroke", "currentColor");
      g.innerHTML = GLYPHS[i % GLYPHS.length];
      var w = document.createElement("b"); w.className = "ef-sgr__w"; w.textContent = words[i % words.length];
      t.appendChild(n); t.appendChild(g); t.appendChild(w);
      grid.appendChild(t);
      tiles.push({ el: t, g: g, i: i });
    }
  }
  // Rank each tile by the chosen order; tiles with equal rank start together.
  function ranks() {
    var cols = opts.columns, n = tiles.length, rows = Math.ceil(n / cols);
    var cx = (cols - 1) / 2, cy = (rows - 1) / 2;
    var dist = tiles.map(function (t) { var x = t.i % cols, y = Math.floor(t.i / cols); return Math.hypot(x - cx, y - cy); });
    if (opts.order === "start") return tiles.map(function (t) { return t.i; });
    if (opts.order === "center" || opts.order === "edges") {
      var max = Math.max.apply(null, dist) || 1;
      return dist.map(function (d) { var k = d / max * (Math.max(cols, rows) - 1); return opts.order === "center" ? k : (Math.max(cols, rows) - 1) - k; });
    }
    var idx = tiles.map(function (t) { return t.i; });
    for (var i = idx.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var s = idx[i]; idx[i] = idx[j]; idx[j] = s; }
    var r = []; idx.forEach(function (ti, k) { r[ti] = k; });
    return r;
  }
  function play() {
    stop();
    box.classList.remove("is-pending");
    if (!tiles.length || !tiles[0].el.animate) return;
    if (reduce) {
      anims.push(grid.animate([{ opacity: 0.35 }, { opacity: 1 }], { duration: 400, easing: "ease-out" }));
      return;
    }
    var r = ranks(), step = Number(opts.stagger);
    tiles.forEach(function (t, k) {
      var d = r[k] * step;
      anims.push(t.el.animate([
        { opacity: 0, transform: "translateY(55%) rotateX(-60deg) scale(0.8)" },
        { opacity: 1, transform: "none" }
      ], { duration: 850, delay: d, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "backwards" }));
      anims.push(t.g.animate([
        { transform: "rotate(-90deg) scale(0.3)" },
        { transform: "none" }
      ], { duration: 750, delay: d + 220, easing: "cubic-bezier(0.34, 1.56, 0.64, 1)", fill: "backwards" }));
    });
  }
  btn.addEventListener("click", play);

  function update(next) {
    opts = Object.assign({ count: 12, columns: 4, order: "random", stagger: 70, words: "Motion, Form, Direction, Rhythm", accent: "#e6ff55", background: "#0c1e18" }, opts, next || {});
    opts.count = Math.min(16, Math.max(4, Number(opts.count) || 12));
    opts.columns = Math.min(6, Math.max(2, Number(opts.columns) || 4));
    box.style.setProperty("--ef-sgr-cols", opts.columns);
    box.style.setProperty("--ef-sgr-acc", opts.accent);
    box.style.setProperty("--ef-sgr-bg", opts.background);
    build();
    if (played) play();
    else if (!reduce && tiles[0].el.animate) box.classList.add("is-pending");
  }
  update(o);
  if ("IntersectionObserver" in window) {
    io = new IntersectionObserver(function (e) { if (e[0].isIntersecting && !played) { played = true; play(); io.disconnect(); } }, { threshold: 0.25 });
    io.observe(grid);
  } else { played = true; play(); }
  return { update: update, destroy: function () { stop(); if (io) io.disconnect(); btn.removeEventListener("click", play); } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"count":12,"columns":4,"order":"random","stagger":70,"words":"Motion, Form, Direction, Rhythm","accent":"#e6ff55","background":"#0c1e18"});

How it works

Each tile gets a rank from the chosen order: its index for reading order, its distance from the grid's centre for centre-out (reversed for edges-in), or a shuffle for random. The Web Animations API then flips each tile up from below, rotated back on its bottom edge and scaled down, delayed by its rank times the stagger. A second, overshooting animation spins the tile's glyph in a little later. Fill mode backwards keeps a tile hidden only while it waits, so once it finishes the tile is an ordinary element with no lingering styles. An IntersectionObserver starts the reveal the first time a quarter of the grid is visible.

Accessibility and performance

Tiles are articles whose text is in the page from the start, and the glyphs are decorative and hidden from assistive technology. Replay is a real button. With prefers-reduced-motion the tiles are shown at once and Replay only does a short fade.

Only transform and opacity are animated, on the compositor, and nothing runs once the reveal has finished. The observer disconnects after the first play.

SectionGallery
TechniquesStagger, Reveal and intro, CSS 3D
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byGSAP Staggered Grid Reveal by Enrichr on CodePen. 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. Split text revealA headline whose words rise out of a mask one after another when it scrolls into view.

FAQ

Can I use my own cards?

Yes. Keep the .ef-sgr__tile class on your cards; the reveal only needs the tiles and the column count to work out the order.

Can it play every time it scrolls into view?

Yes. Keep the observer connected and call the reveal again whenever the grid re-enters the screen.

Why the Web Animations API instead of CSS classes?

Each tile needs its own delay that changes with the order, and the API lets every animation be cancelled cleanly when you press Replay.