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 "Clip menu with ease-reverse close" for my site using the Enrichr Library element at https://enrichr.dev/elements/ease-reverse-clip-menu.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/ease-reverse-clip-menu.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
- easeReverse: on
- exitSpeed: 1.5
- distance: 420
- tiles: 12
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/ease-reverse-clip-menu.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-erm">
<svg class="ef-erm__defs" width="0" height="0" aria-hidden="true" focusable="false">
<symbol id="ef-erm-pz1" 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-erm-pz4" 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-erm-pz8" 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>
</svg>
<div class="ef-erm__stage">
<div class="ef-erm__cover" aria-hidden="true"></div>
<nav class="ef-erm__menu" aria-label="Example menu" inert>
<svg class="ef-erm__bgpz" aria-hidden="true"><use href="#ef-erm-pz4"/></svg>
<ul class="ef-erm__list">
<li><a class="ef-erm__link" href="#"><span class="ef-erm__n">01</span><span class="ef-erm__t">Work</span></a></li>
<li><a class="ef-erm__link" href="#"><span class="ef-erm__n">02</span><span class="ef-erm__t">Studio</span></a></li>
<li><a class="ef-erm__link" href="#"><span class="ef-erm__n">03</span><span class="ef-erm__t">Journal</span></a></li>
<li><a class="ef-erm__link" href="#"><span class="ef-erm__n">04</span><span class="ef-erm__t">Contact</span></a></li>
</ul>
</nav>
<button class="ef-erm__toggle" type="button" aria-expanded="false" aria-label="Open menu">
<svg viewBox="0 0 9 9" aria-hidden="true"><rect x="0" y="0" width="9" height="1"/><rect x="0" y="4" width="9" height="1"/><rect x="0" y="8" width="9" height="1"/></svg>
<span>Menu</span>
</button>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
/* Adapted from https://github.com/codrops/EaseReverseClipMenu (MIT) — Copyright (c) 2009 - 2025 [Codrops](https://codrops.com) */
.ef-erm { --ef-acc: #e6ff55; position: relative; width: 100%; height: 100%; min-height: 26rem; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #fbf8f1; }
.ef-erm__defs { position: absolute; width: 0; height: 0; overflow: hidden; }
.ef-erm__stage { position: absolute; inset: 0; overflow: hidden; background: #0c1e18; }
.ef-erm__cover { position: absolute; inset: 0; display: grid; gap: 0.5rem; padding: 0.5rem; box-sizing: border-box; }
.ef-erm__tile { position: relative; border-radius: 10px; overflow: hidden; will-change: transform, opacity; color: var(--ef-acc); }
.ef-erm__tile svg { position: absolute; left: 50%; top: 50%; width: 62%; height: 62%; transform: translate(-50%, -50%) rotate(var(--r, 0deg)); }
.ef-erm__tile span { position: absolute; left: 0.5rem; bottom: 0.35rem; font-size: 0.62rem; letter-spacing: 0.14em; font-weight: 700; color: #fbf8f1; opacity: 0.85; }
.ef-erm__menu { position: absolute; inset: 0; z-index: 2; display: flex; align-items: center; padding: 0 8%; overflow: hidden; background: radial-gradient(ellipse at 20% 100%, #0a6b46, transparent 70%), linear-gradient(150deg, #005236, #0c1e18); clip-path: polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%); visibility: hidden; will-change: clip-path; }
.ef-erm__bgpz { position: absolute; right: -8%; bottom: -14%; width: 62%; height: 62%; color: var(--ef-acc); opacity: 0.12; transform: rotate(-12deg); pointer-events: none; }
.ef-erm__list { position: relative; list-style: none; margin: 0; padding: 0; width: 100%; }
.ef-erm__link { display: flex; align-items: baseline; gap: 0.9rem; padding: 0.15rem 0; color: #fbf8f1; text-decoration: none; font-size: clamp(2rem, 9vw, 3.6rem); font-weight: 800; letter-spacing: -0.03em; line-height: 1.08; outline: none; transition: color 200ms, transform 300ms cubic-bezier(.2, .8, .2, 1); }
.ef-erm__n { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.14em; color: var(--ef-acc); }
.ef-erm__link:hover, .ef-erm__link:focus-visible { color: var(--ef-acc); transform: translateX(0.6rem); }
.ef-erm__link:focus-visible .ef-erm__t { text-decoration: underline; text-decoration-thickness: 3px; text-underline-offset: 6px; }
.ef-erm__toggle { position: absolute; top: 0.9rem; right: 0.9rem; z-index: 4; display: inline-flex; align-items: center; gap: 0.55rem; padding: 0.55rem 0.95rem 0.55rem 0.8rem; border: 0; border-radius: 999px; background: var(--ef-acc); color: #0c1e18; font: inherit; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.04em; cursor: pointer; transition: transform 200ms; }
.ef-erm__toggle:hover { transform: scale(1.05); }
.ef-erm__toggle:focus-visible { outline: 2px solid #fbf8f1; outline-offset: 3px; }
.ef-erm__toggle svg { width: 1rem; height: 1rem; fill: currentColor; overflow: visible; }
.ef-erm__toggle rect { transform-box: fill-box; transform-origin: center; transition: transform 300ms cubic-bezier(.2, .8, .2, 1), opacity 200ms; }
.ef-erm__toggle[aria-expanded="true"] rect:nth-child(1) { transform: translateY(4px) rotate(45deg); }
.ef-erm__toggle[aria-expanded="true"] rect:nth-child(2) { opacity: 0; }
.ef-erm__toggle[aria-expanded="true"] rect:nth-child(3) { transform: translateY(-4px) rotate(-45deg); }
@media (prefers-reduced-motion: reduce) { .ef-erm__link, .ef-erm__toggle, .ef-erm__toggle rect { transition: none; } .ef-erm__link:hover, .ef-erm__link:focus-visible, .ef-erm__toggle:hover { transform: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Adapted from https://github.com/codrops/EaseReverseClipMenu (MIT) — Copyright (c) 2009 - 2025 [Codrops](https://codrops.com)
// Ease-reverse clip menu: a grid of tiles flies radially outward (centre tiles leave last) while a menu opens as a clip-path from the middle.
// The point is the way back. Played in reverse, each motion keeps its own "out" curve instead of replaying the opening curve backwards, so
// closing starts fast and settles softly; a full close lets the tiles overshoot home with an elastic; closing mid-open runs at a faster speed.
// Vanilla port: one master clock (0 to 1.1 s) read by per-item windows, so no timeline library is needed.
// mount(root, { accent, easeReverse ("on" | "off"), exitSpeed (1-4), distance (px), tiles (6-16) })
function mount(root, o) {
var mq = window.matchMedia("(prefers-reduced-motion: reduce)");
var stage = root.querySelector(".ef-erm__stage");
var cover = root.querySelector(".ef-erm__cover");
var menu = root.querySelector(".ef-erm__menu");
var toggle = root.querySelector(".ef-erm__toggle");
var links = [].slice.call(root.querySelectorAll(".ef-erm__link"));
var T = 1.1, MENU_DELAY = 0.3, MENU_DUR = 0.8, TILE_DUR = 0.7, TILE_MAX_DELAY = 0.3;
var opts = {}, tiles = [], t = 0, dir = 0, speed = 1, mode = "fwd", isOpen = false;
var raf = 0, last = 0, corrK = 0, menuC = 0, ro = null, offs = [], built = "";
function clamp(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function expoOut(u) { return u >= 1 ? 1 : u <= 0 ? 0 : 1 - Math.pow(2, -10 * u); }
function elasticOut(u) { return u >= 1 ? 1 : u <= 0 ? 0 : Math.pow(2, -10 * u) * Math.sin((u - 0.075) * (2 * Math.PI) / 0.3) + 1; }
// eased value (0 = closed, 1 = open) for a window at the current clock; kind is "tile" or "menu"
function val(start, dur, kind) {
var u = clamp((t - start) / dur);
if (mode === "fwd" || opts.easeReverse !== "on") return expoOut(u);
if (kind === "tile" && mode === "full") return 1 - elasticOut(1 - u);
return 1 - expoOut(1 - u);
}
function art(i, a) {
var d = "#0c1e18", c = "#fbf8f1", p = i % 5;
if (p === 0) return "linear-gradient(155deg, #0a6b46, " + d + ")";
if (p === 1) return "linear-gradient(200deg, #c6f26b -30%, #2f7d5b 45%, #005236)";
if (p === 2) return "radial-gradient(circle at 70% 30%, " + a + " 0 16%, transparent 17%), linear-gradient(160deg, #005236, " + d + ")";
if (p === 3) return "repeating-linear-gradient(125deg, #005236 0 10px, #0a6b46 10px 20px)";
return "conic-gradient(from 200deg at 30% 70%, #005236, #2f7d5b, " + c + " 40%, #005236)";
}
function build() {
var n = Math.max(6, Math.min(16, Math.round(opts.tiles)));
cover.textContent = "";
tiles = [];
var W = stage.clientWidth || 520, H = stage.clientHeight || 520;
var cols = Math.max(2, Math.min(n, Math.round(Math.sqrt(n * W / H))));
var rows = Math.ceil(n / cols);
cover.style.gridTemplateColumns = "repeat(" + cols + ", 1fr)";
cover.style.gridTemplateRows = "repeat(" + rows + ", 1fr)";
var pz = ["#ef-erm-pz1", "#ef-erm-pz4", "#ef-erm-pz8"];
for (var i = 0; i < n; i++) {
var el = document.createElement("div");
el.className = "ef-erm__tile";
el.style.background = art(i, opts.accent);
var nm = document.createElement("span"); nm.textContent = String(i + 1).padStart(2, "0");
var ns = "http://www.w3.org/2000/svg";
var sv = document.createElementNS(ns, "svg");
var us = document.createElementNS(ns, "use");
us.setAttribute("href", pz[i % 3]);
sv.appendChild(us);
sv.style.setProperty("--r", (((i * 29) % 40) - 20) + "deg");
if (i % 5 === 1) el.style.color = "#0c1e18";
if (i % 5 === 4) el.style.color = "#0a6b46";
el.appendChild(sv); el.appendChild(nm);
cover.appendChild(el);
tiles.push({ el: el, x: 0, y: 0, delay: 0, rot: (((i * 37) % 61) / 61) * 60 - 30, c: 0 });
}
layout();
}
function layout() {
var W = stage.clientWidth, H = stage.clientHeight;
var maxD = Math.hypot(W / 2, H / 2) || 1;
tiles.forEach(function (it) {
var dx = it.el.offsetLeft + it.el.offsetWidth / 2 - W / 2;
var dy = it.el.offsetTop + it.el.offsetHeight / 2 - H / 2;
if (dx === 0 && dy === 0) { dx = 1; dy = 1; }
var len = Math.hypot(dx, dy);
it.x = dx / len * opts.distance; it.y = dy / len * opts.distance;
it.delay = TILE_MAX_DELAY * (1 - clamp(len / maxD));
});
render();
}
function render() {
var reduce = mq.matches;
var vm;
if (reduce) {
vm = isOpen ? 1 : 0;
tiles.forEach(function (it) { it.el.style.transform = "none"; it.el.style.opacity = isOpen ? "0" : "1"; });
} else {
vm = val(MENU_DELAY, MENU_DUR, "menu") + menuC * corrK;
tiles.forEach(function (it) {
var v = val(it.delay, TILE_DUR, "tile") + it.c * corrK;
it.el.style.transform = "translate3d(" + (it.x * v).toFixed(1) + "px," + (it.y * v).toFixed(1) + "px,0) rotate(" + (it.rot * v).toFixed(1) + "deg)";
it.el.style.opacity = String(clamp(1 - v).toFixed(3));
});
}
var a = 50 * (1 - vm), b = 50 + 50 * vm;
menu.style.visibility = vm > 0.002 ? "visible" : "hidden";
menu.style.clipPath = "polygon(" + a.toFixed(2) + "% " + a.toFixed(2) + "%, " + b.toFixed(2) + "% " + a.toFixed(2) + "%, " + b.toFixed(2) + "% " + b.toFixed(2) + "%, " + a.toFixed(2) + "% " + b.toFixed(2) + "%)";
}
function tick(now) {
raf = 0;
var dt = last ? Math.min(64, now - last) : 16;
last = now;
var busy = false;
if (dir !== 0) {
t += dir * speed * dt / 1000;
if (t >= T) { t = T; dir = 0; } else if (t <= 0) { t = 0; dir = 0; } else busy = true;
}
if (corrK > 0.002) { corrK *= Math.exp(-dt / 110); busy = true; } else corrK = 0;
render();
if (busy) raf = requestAnimationFrame(tick); else last = 0;
}
function kick() { if (!raf) raf = requestAnimationFrame(tick); }
// switch mode without a visual jump: remember the gap and let it fade out
function setMode(m) {
var before = tiles.map(function (it) { return val(it.delay, TILE_DUR, "tile") + it.c * corrK; });
var beforeM = val(MENU_DELAY, MENU_DUR, "menu") + menuC * corrK;
mode = m;
tiles.forEach(function (it, i) { it.c = before[i] - val(it.delay, TILE_DUR, "tile"); });
menuC = beforeM - val(MENU_DELAY, MENU_DUR, "menu");
corrK = 1;
}
function sync() {
toggle.setAttribute("aria-expanded", String(isOpen));
toggle.setAttribute("aria-label", isOpen ? "Close menu" : "Open menu");
if (isOpen) menu.removeAttribute("inert"); else menu.setAttribute("inert", "");
}
function flip() {
var reduce = mq.matches;
var wasFull = t >= T - 0.001;
isOpen = !isOpen;
sync();
if (reduce) { t = isOpen ? T : 0; dir = 0; mode = "fwd"; corrK = 0; render(); return; }
if (isOpen) { setMode("fwd"); dir = 1; speed = 1; }
else { setMode(wasFull ? "full" : "int"); dir = -1; speed = wasFull ? 1 : opts.exitSpeed; }
kick();
}
function onKey(e) { if (e.key === "Escape" && isOpen) { e.preventDefault(); flip(); toggle.focus(); } }
function noop(e) { e.preventDefault(); }
function on(el, ev, fn) { el.addEventListener(ev, fn); offs.push(function () { el.removeEventListener(ev, fn); }); }
on(toggle, "click", flip);
on(root, "keydown", onKey);
links.forEach(function (l) { on(l, "click", noop); });
if (typeof ResizeObserver !== "undefined") { ro = new ResizeObserver(function () { layout(); }); ro.observe(stage); }
function update(next) {
var prev = opts;
opts = Object.assign({ accent: "#e6ff55", easeReverse: "on", exitSpeed: 1.5, distance: 420, tiles: 12 }, opts, next || {});
opts.tiles = Math.max(6, Math.min(16, Math.round(opts.tiles)));
opts.distance = Math.max(100, +opts.distance);
opts.exitSpeed = Math.max(1, Math.min(4, +opts.exitSpeed));
root.style.setProperty("--ef-acc", opts.accent);
var key = opts.tiles + "|" + opts.accent;
if (key !== built) { built = key; build(); }
else if (prev.distance !== opts.distance) layout();
else render();
}
update(o);
sync();
return {
update: update,
destroy: function () {
offs.forEach(function (f) { f(); }); offs = [];
if (ro) { ro.disconnect(); ro = null; }
if (raf) cancelAnimationFrame(raf);
raf = 0; dir = 0;
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","easeReverse":"on","exitSpeed":1.5,"distance":420,"tiles":12});How it works
This is a vanilla port of the Codrops easeReverse clip menu, with no GSAP. A single clock runs from 0 to 1.1 seconds. Each tile and the menu read it through their own window (start and duration), and tiles nearer the centre start later. Going forward every value uses an expo-out curve. Going backward the value is 1 minus the curve evaluated on the remaining time, so the closing motion starts fast and settles softly instead of replaying the opening backwards. A full close swaps the tile curve for an elastic out so tiles overshoot home. Closing before the opening finished runs the clock backward faster, set by the speed control. When the curve changes, the gap is remembered and faded out over about a tenth of a second so nothing jumps.
Accessibility and performance
The toggle is a button with aria-expanded and a changing label. The menu is inert while closed, so its links cannot take focus or be read, and Escape closes it and returns focus to the toggle. The tile cover is decorative and hidden from assistive tech. With prefers-reduced-motion the menu simply appears and the tiles hide with no travel.
Each frame writes one transform and opacity per tile and one clip-path on the menu. Tile positions are measured on build and on resize, not per frame. The loop stops when the clock reaches either end and the jump correction has faded.
| Section | Navigation and lists |
|---|---|
| Techniques | Reveal and intro, Stagger, Pointer interaction |
| 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 | Clip Menu with GSAP easeReverse 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
What does ease reverse change?
With a plain reverse, the opening curve is played backwards, so the close begins slowly and rushes at the end. Using the out-curve for the way back makes the close respond immediately and land softly, which feels more intentional.
Why does the interrupt speed exist?
If someone closes the menu before it finished opening, there is less to undo, so it can run faster. The control sets how much faster, from 1 to 4 times.
Can the tiles be photos?
Yes. Replace the tile background with an image. The motion only reads the tile position and uses transform and opacity.