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 "Menu rows to grid layout" for my site using the Enrichr Library element at https://enrichr.dev/elements/menu-rows-to-grid.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/menu-rows-to-grid.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:
- titles: Nobody's Love, Good Wings, Wild Honey, Slow Orbit, Paper Moon
- accent: #e6ff55
- cover: #005236
- duration: 0.9
- rows: 4
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/menu-rows-to-grid.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-mtg">
<svg class="ef-mtg__defs" width="0" height="0" aria-hidden="true" focusable="false"><defs>
<symbol id="efmtg-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="efmtg-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="efmtg-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-mtg__stage"></div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
/* Adapted from https://github.com/codrops/MenuToGrid (MIT) — Copyright (c) 2009 - 2021 Codrops */
.ef-mtg { width: 100%; }
.ef-mtg__defs { position: absolute; width: 0; height: 0; overflow: hidden; }
.ef-mtg__stage { --acc: #e6ff55; --cov: #005236; position: relative; box-sizing: border-box; width: 100%; height: 24rem; overflow: hidden; border-radius: 1rem; background: #0c1e18; color: #fbf8f1; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-mtg__rows { position: absolute; inset: 0; display: flex; flex-direction: column; }
.ef-mtg__row { flex: 1 1 0; min-height: 0; display: flex; align-items: center; justify-content: space-between; gap: 1rem; box-sizing: border-box; width: 100%; padding: 0 1.2rem; margin: 0; background: none; border: 0; border-top: 1px solid rgba(251, 248, 241, 0.18); color: inherit; font: inherit; text-align: left; cursor: pointer; }
.ef-mtg__row:last-child { border-bottom: 1px solid rgba(251, 248, 241, 0.18); }
.ef-mtg__row:focus-visible { outline: 2px solid var(--acc); outline-offset: -4px; }
.ef-mtg__t { display: block; overflow: hidden; padding: 0.15em 0; }
.ef-mtg__t > span { display: block; font-size: 1.65rem; font-weight: 800; letter-spacing: -0.03em; line-height: 1.05; text-transform: uppercase; transform-origin: 0 50%; will-change: transform; }
.ef-mtg__t.is-switch > span { color: var(--acc); font-style: italic; letter-spacing: -0.045em; }
.ef-mtg__imgs { display: flex; gap: 0.45rem; height: 62%; max-height: 4.2rem; flex: 0 0 auto; }
.ef-mtg__img { position: relative; overflow: hidden; border-radius: 0.4rem; flex: 0 0 auto; transform-origin: 0 0; }
.ef-mtg__imgs .ef-mtg__img { width: 3.1rem; height: 100%; opacity: 0; transform: translateX(20%) scale(0.8); transition: opacity 400ms cubic-bezier(.2,.8,.2,1), transform 400ms cubic-bezier(.2,.8,.2,1); }
.ef-mtg__imgs .ef-mtg__img:nth-child(1) { transition-delay: 105ms; }
.ef-mtg__imgs .ef-mtg__img:nth-child(2) { transition-delay: 70ms; }
.ef-mtg__imgs .ef-mtg__img:nth-child(3) { transition-delay: 35ms; }
.ef-mtg__row.is-hover .ef-mtg__imgs .ef-mtg__img { opacity: 1; transform: none; }
.ef-mtg__row.is-snap .ef-mtg__imgs .ef-mtg__img { transition: none; transition-delay: 0s; opacity: 1; transform: none; }
.ef-mtg__piece { position: absolute; left: 50%; top: 50%; width: 64%; height: 64%; transform: translate(-50%, -50%); display: block; opacity: 0.92; }
.ef-mtg__cover { position: absolute; inset: 0; z-index: 2; background: var(--cov); opacity: 0; pointer-events: none; clip-path: inset(0 0 100% 0); }
.ef-mtg__prev { position: absolute; inset: 0; z-index: 3; display: none; flex-direction: column; box-sizing: border-box; padding: 1rem 1.2rem 1.2rem; }
.ef-mtg__prev.is-current { display: flex; }
.ef-mtg__pt { margin: 0; overflow: hidden; padding: 0.1em 0; flex: 0 0 auto; }
.ef-mtg__pt > span { display: block; font-size: 1.9rem; font-weight: 800; letter-spacing: -0.04em; line-height: 1.05; text-transform: uppercase; color: var(--acc); font-style: italic; transform-origin: 100% 50%; will-change: transform; }
.ef-mtg__grid { flex: 1 1 0; min-height: 0; margin-top: 0.8rem; display: grid; grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(2, 1fr); gap: 0.55rem; }
.ef-mtg__grid .ef-mtg__img { width: 100%; height: 100%; border-radius: 0.5rem; }
.ef-mtg__close { position: absolute; z-index: 4; top: 1.05rem; right: 1.2rem; display: none; padding: 0.45rem 0.8rem; border-radius: 99px; border: 1.5px solid rgba(251, 248, 241, 0.7); background: none; color: #fbf8f1; font: inherit; font-size: 0.68rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; cursor: pointer; opacity: 0; transition: background 250ms, color 250ms; }
.ef-mtg__close.is-show { display: block; }
.ef-mtg__close:hover { background: var(--acc); color: #0c1e18; border-color: var(--acc); }
.ef-mtg__close:focus-visible { outline: 2px solid var(--acc); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .ef-mtg__imgs .ef-mtg__img { transition: opacity 200ms; transform: none; } .ef-mtg__close { transition: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Adapted from https://github.com/codrops/MenuToGrid (MIT) — Copyright (c) 2009 - 2021 Codrops
// Menu rows to grid: hovering a row slides a strip of images in and swaps its title; clicking expands a cover over the whole box and the row's
// images fly (hand-rolled FLIP, measured first and last rectangles) into a grid where more tiles pop in. Close reverses it.
// Vanilla port, no GSAP or Flip plugin: everything uses the Web Animations API. Rows are real buttons, Escape closes, reduced motion skips the movement.
// mount(root, { titles (comma list), accent, cover, duration (s), rows (2-5) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var stage = root.querySelector(".ef-mtg__stage");
var opts = {}, rows = [], anims = [], offs = [], timers = [];
var cur = -1, busy = false, isOpen = false;
var EASE = "cubic-bezier(0.83, 0, 0.17, 1)", EXPO = "cubic-bezier(0.16, 1, 0.3, 1)";
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"];
var cover, closeBtn, rowsWrap;
function el(tag, cls, txt) { var e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }
function ms(f) { return reduce ? 1 : Math.max(1, opts.duration * 1000 * f); }
function play(node, kf, o2) {
var a = node.animate(kf, Object.assign({ fill: "both" }, o2));
anims.push(a);
return a;
}
function flush() { anims.forEach(function (a) { try { a.cancel(); } catch (e) {} }); anims = []; }
function later(fn, t) { var id = setTimeout(fn, t); timers.push(id); return id; }
function clearTimers() { timers.forEach(clearTimeout); timers = []; }
function art(i, withPiece) {
var d = el("div", "ef-mtg__img");
d.style.background = GRADS[i % GRADS.length];
if (withPiece) {
var s = document.createElementNS("http://www.w3.org/2000/svg", "svg");
s.setAttribute("class", "ef-mtg__piece"); s.setAttribute("aria-hidden", "true"); s.setAttribute("focusable", "false");
var u = document.createElementNS("http://www.w3.org/2000/svg", "use");
u.setAttribute("href", "#efmtg-" + PIECES[i % 3]);
s.appendChild(u);
s.style.color = i % 3 === 0 ? opts.accent : i % 3 === 1 ? "#fbf8f1" : "#c6f26b";
d.appendChild(s);
}
return d;
}
function on(t, ev, fn) { t.addEventListener(ev, fn); offs.push(function () { t.removeEventListener(ev, fn); }); }
function teardown() {
flush(); clearTimers();
offs.forEach(function (f) { f(); }); offs = [];
stage.textContent = ""; rows = []; cur = -1; busy = false; isOpen = false;
}
function build() {
teardown();
stage.style.setProperty("--acc", opts.accent);
stage.style.setProperty("--cov", opts.cover);
var names = String(opts.titles || "").split(",").map(function (s) { return s.trim(); }).filter(Boolean);
if (!names.length) names = ["Untitled"];
rowsWrap = el("div", "ef-mtg__rows");
cover = el("div", "ef-mtg__cover");
closeBtn = el("button", "ef-mtg__close", "Close");
closeBtn.type = "button"; closeBtn.setAttribute("aria-label", "Close and return to the menu");
stage.appendChild(rowsWrap); stage.appendChild(cover);
for (var r = 0; r < opts.rows; r++) (function (r) {
var btn = el("button", "ef-mtg__row"); btn.type = "button";
var tw = el("span", "ef-mtg__t"), t = el("span", "", names[r % names.length]); tw.appendChild(t);
var imgs = el("span", "ef-mtg__imgs"); imgs.setAttribute("aria-hidden", "true");
var list = [];
for (var k = 0; k < 4; k++) { var im = art(r * 4 + k, k % 2 === 0); imgs.appendChild(im); list.push(im); }
btn.appendChild(tw); btn.appendChild(imgs);
btn.setAttribute("aria-label", "Open " + names[r % names.length]);
rowsWrap.appendChild(btn);
var prev = el("div", "ef-mtg__prev"); prev.setAttribute("role", "region"); prev.setAttribute("aria-label", names[r % names.length]);
var pt = el("h3", "ef-mtg__pt"), pts = el("span", "", names[r % names.length]); pt.appendChild(pts);
var grid = el("div", "ef-mtg__grid"), extras = [];
for (var j = 0; j < 4; j++) { var ex = art(r * 4 + 4 + j, j % 2 === 1); ex.style.opacity = "1"; grid.appendChild(ex); extras.push(ex); }
prev.appendChild(pt); prev.appendChild(grid);
stage.appendChild(prev);
var row = { btn: btn, tw: tw, t: t, imgs: imgs, list: list, prev: prev, pt: pts, grid: grid, extras: extras };
rows.push(row);
function enter() { if (isOpen || busy) return; hover(row, true); }
function leave() { if (isOpen || busy) return; hover(row, false); }
on(btn, "pointerenter", enter); on(btn, "pointerleave", leave);
on(btn, "focus", enter); on(btn, "blur", leave);
on(btn, "click", function () { open(r); });
})(r);
stage.appendChild(closeBtn);
on(closeBtn, "click", close);
on(stage, "keydown", function (e) { if (e.key === "Escape" && isOpen) close(); });
}
function hover(row, state) {
if (row.hov === state) return;
row.hov = state;
row.btn.classList.toggle("is-hover", state);
if (reduce) { row.tw.classList.toggle("is-switch", state); return; }
var a = play(row.t, [{ transform: "translateY(0)" }, { transform: "translateY(-100%)" }], { duration: 100, easing: "ease-in" });
a.onfinish = function () {
row.tw.classList.toggle("is-switch", state);
play(row.t, [{ transform: "translateY(100%) rotate(15deg)" }, { transform: "translateY(0) rotate(0)" }], { duration: 500, easing: EXPO });
};
}
function open(i) {
if (busy || isOpen) return;
busy = true; isOpen = true; cur = i;
var row = rows[i], H = stage.clientHeight;
var top = row.btn.offsetTop, h = row.btn.offsetHeight;
row.hov = true; row.btn.classList.add("is-hover", "is-snap");
row.tw.classList.add("is-switch");
var D = ms(1);
rows.forEach(function (rw) { rw.btn.tabIndex = -1; rw.btn.setAttribute("aria-hidden", "true"); });
// cover grows from the row to the full box
cover.style.opacity = "1";
play(cover, [{ clipPath: "inset(" + top + "px 0 " + (H - top - h) + "px 0)" }, { clipPath: "inset(0 0 0 0)" }], { duration: D, easing: EASE });
// every title slides out, away from the clicked row
rows.forEach(function (rw, n) {
play(rw.t, [{ transform: "translateY(0)" }, { transform: "translateY(" + (n > i ? 110 : -110) + "%)" }], { duration: ms(0.55), easing: EASE });
});
// FLIP the row's images into the preview grid
var first = row.list.map(function (im) { return im.getBoundingClientRect(); });
row.prev.classList.add("is-current");
for (var k = row.list.length - 1; k >= 0; k--) row.grid.insertBefore(row.list[k], row.grid.firstChild);
row.list.forEach(function (im, k2) {
var l = im.getBoundingClientRect(), f = first[k2];
play(im, [
{ transformOrigin: "0 0", transform: "translate(" + (f.left - l.left) + "px," + (f.top - l.top) + "px) scale(" + (f.width / l.width) + "," + (f.height / l.height) + ")" },
{ transformOrigin: "0 0", transform: "none" }
], { duration: D, easing: EASE, delay: reduce ? 0 : k2 * 40 });
});
row.extras.forEach(function (ex, j) {
play(ex, [{ opacity: 0, transform: "scale(0) translateY(" + Math.round(Math.random() * 120) + "%)" }, { opacity: 1, transform: "none" }], { duration: D, easing: EASE, delay: reduce ? 0 : (4 + j) * 40 });
});
// preview title and close button
play(row.pt, [{ transform: "translateY(-100%) rotate(15deg)" }, { transform: "translateY(0) rotate(0)" }], { duration: D, easing: EASE });
closeBtn.classList.add("is-show");
play(closeBtn, [{ opacity: 0 }, { opacity: 1 }], { duration: D, easing: EASE });
later(function () { busy = false; closeBtn.focus({ preventScroll: true }); }, D + 200);
}
function close() {
if (busy || !isOpen) return;
busy = true;
var row = rows[cur], H = stage.clientHeight;
var top = row.btn.offsetTop, h = row.btn.offsetHeight, D = ms(0.55);
var all = row.list.concat(row.extras);
all.forEach(function (im, n) {
play(im, [{ opacity: 1, transform: "none" }, { opacity: 0, transform: "scale(0)" }], { duration: D, easing: EASE, delay: reduce ? 0 : n * 40 });
});
play(row.pt, [{ transform: "translateY(0)" }, { transform: "translateY(100%)" }], { duration: ms(0.65), easing: EASE });
play(closeBtn, [{ opacity: 1 }, { opacity: 0 }], { duration: D, easing: EASE });
var cd = reduce ? 0 : D * 0.8;
play(cover, [{ clipPath: "inset(0 0 0 0)", opacity: 1 }, { clipPath: "inset(" + (top + h / 2) + "px 0 " + (H - top - h / 2) + "px 0)", opacity: 1 }], { duration: ms(0.55), easing: EXPO, delay: cd });
later(function () {
// titles slide back, staggered outward from the closed row
rows.forEach(function (rw, n) {
var from = n > cur ? 110 : -110;
play(rw.t, [{ transform: "translateY(" + from + "%)" }, { transform: "translateY(0)" }], { duration: ms(0.5), easing: EASE, delay: reduce ? 0 : Math.abs(n - cur) * 30 });
});
}, cd + ms(0.3));
later(function () {
row.list.forEach(function (im) { row.imgs.appendChild(im); });
flush();
cover.style.opacity = "0";
row.prev.classList.remove("is-current");
closeBtn.classList.remove("is-show");
row.btn.classList.remove("is-hover", "is-snap");
row.tw.classList.remove("is-switch");
row.hov = false;
rows.forEach(function (rw) { rw.btn.tabIndex = 0; rw.btn.removeAttribute("aria-hidden"); });
isOpen = false; busy = false;
row.btn.focus({ preventScroll: true });
}, cd + ms(0.55) + ms(0.3) + 60);
}
function update(next) {
var prev = opts;
opts = Object.assign({ titles: "Nobody's Love, Good Wings, Wild Honey, Slow Orbit, Paper Moon", accent: "#e6ff55", cover: "#005236", duration: 0.9, rows: 4 }, opts, next || {});
opts.rows = Math.max(2, Math.min(5, Math.round(+opts.rows || 4)));
opts.duration = Math.max(0.2, Math.min(3, +opts.duration || 0.9));
if (prev.titles !== opts.titles || prev.accent !== opts.accent || prev.cover !== opts.cover || prev.rows !== opts.rows) build();
}
update(o);
return { update: update, destroy: teardown };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"titles":"Nobody's Love, Good Wings, Wild Honey, Slow Orbit, Paper Moon","accent":"#e6ff55","cover":"#005236","duration":0.9,"rows":4});How it works
This is a vanilla port with no GSAP or Flip plugin; everything uses the Web Animations API. Hover reveals each row's four images with staggered CSS transitions and swaps the title with a short slide out and a rotated slide in. On click, a cover element clips open from the clicked row's rectangle to the whole box, the titles slide away, and the row's images are moved into a preview grid. Their first and last rectangles are measured, and each image animates from the inverse transform to none (a hand-written FLIP) while the remaining grid tiles pop in. Close reverses the sequence and puts the images back in their row.
Accessibility and performance
Rows are real buttons with labels, so the menu works from the keyboard and focus shows the same hover preview. Focus moves to Close when a row opens and back to the row afterwards, and Escape closes. Hidden rows are removed from the tab order while the grid is open. With reduced motion the image strip fades and everything else changes state with no travel.
The cover animates clip-path and the images animate transform and opacity. Nothing runs when idle. Animations are cancelled on close and on destroy, so no timers or listeners remain.
| Section | Navigation and lists |
|---|---|
| Techniques | Reveal and intro, Stagger |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | Menu to Grid Layout Animation 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.
More elements
FAQ
Can the grid hold real photos?
Yes. Give each .ef-mtg__img a background-image in art(). The measuring and FLIP code does not care what is inside.
How many images does each row have?
Four in the row plus four extras that pop into the grid, giving a 4 by 2 layout. Change the loops in build() and the grid-template in the CSS to alter it.
Does the cover need to be a solid colour?
No. It is a single element with a clip-path, so a gradient or image background works as well.