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.
| Section | Gallery |
|---|---|
| Techniques | Stagger, Reveal and intro, CSS 3D |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | GSAP 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.
More elements
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.