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 reveal menu" for my site using the Enrichr Library element at https://enrichr.dev/elements/clip-reveal-menu.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/clip-reveal-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:
- items: Brand identity, Web design, Motion, Packaging, Editorial
- mode: circle
- speed: 650
- color: #fbf8f1
- accent: #e6ff55
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/clip-reveal-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-clip"><ul class="ef-clip__list"></ul></div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-clip { container-type: inline-size; width: 100%; min-height: 16rem; display: flex; align-items: center; box-sizing: border-box; --ef-clip-d: 650ms; }
.ef-clip__list { list-style: none; margin: 0; padding: 0; width: 100%; }
.ef-clip__row { position: relative; overflow: hidden; cursor: pointer; outline: none; border-bottom: 1px solid color-mix(in srgb, var(--ef-clip-c, #fbf8f1) 22%, transparent); }
.ef-clip__row:first-child { border-top: 1px solid color-mix(in srgb, var(--ef-clip-c, #fbf8f1) 22%, transparent); }
.ef-clip__t { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; padding: 0.3em 4%; font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 1.8rem; font-size: clamp(1.4rem, 8.5cqi, 3.1rem); font-weight: 700; line-height: 1.15; letter-spacing: -0.025em; color: var(--ef-clip-c, #fbf8f1); white-space: nowrap; }
.ef-clip__n { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: max(0.62rem, 0.32em); font-weight: 500; letter-spacing: 0.04em; opacity: 0.7; }
.ef-clip__bg { position: absolute; inset: 0; clip-path: var(--c0, inset(50% 0 50% 0)); transition: clip-path var(--ef-clip-d) cubic-bezier(0.7, 0, 0.2, 1); pointer-events: none; }
.ef-clip__row.is-on .ef-clip__bg { clip-path: var(--c1, inset(0)); }
.ef-clip__bg .ef-clip__t { position: absolute; inset: 0; color: var(--ef-clip-k, #0c1e18); }
.ef-clip__row:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-clip-a, #e6ff55); }
.ef-clip.is-still .ef-clip__bg { transition: none; }
.ef-clip__bg[data-p="0"] { background: repeating-linear-gradient(135deg, var(--ef-clip-a, #e6ff55) 0 14px, color-mix(in srgb, var(--ef-clip-a, #e6ff55) 78%, #fff) 14px 28px); }
.ef-clip__bg[data-p="1"] { background: radial-gradient(circle, color-mix(in srgb, var(--ef-clip-k, #0c1e18) 55%, transparent) 1.6px, transparent 2.2px) 0 0 / 14px 14px, var(--ef-clip-a, #e6ff55); }
.ef-clip__bg[data-p="2"] { background: linear-gradient(115deg, color-mix(in srgb, var(--ef-clip-a, #e6ff55) 55%, #7dffc8) 0, var(--ef-clip-a, #e6ff55) 62%); }
.ef-clip__bg[data-p="3"] { background: repeating-conic-gradient(var(--ef-clip-a, #e6ff55) 0 25%, color-mix(in srgb, var(--ef-clip-a, #e6ff55) 74%, #fff) 0 50%) 0 0 / 30px 30px; }
.ef-clip__bg[data-p="4"] { background: repeating-linear-gradient(90deg, color-mix(in srgb, var(--ef-clip-k, #0c1e18) 14%, transparent) 0 2px, transparent 2px 12px), linear-gradient(100deg, var(--ef-clip-a, #e6ff55), color-mix(in srgb, var(--ef-clip-a, #e6ff55) 50%, #c9ffea) 55%, var(--ef-clip-a, #e6ff55)); }JavaScript
// Enrichr Library · https://enrichr.dev
// Clip reveal menu: text rows that open a generated pattern behind the word with a clip-path transition on hover or focus, while the word inverts exactly along the clip edge.
// The pattern is pure CSS gradients, one per row. The inverted word is a second copy of the label inside the clipped layer.
// mount(root, { items (comma separated), mode ("circle" grows from the pointer, "wipe" slides in from the side the pointer entered, "grow" opens from the middle), speed (ms), color (text), accent (pattern colour), ink (text colour on the pattern), demo (1 = rows open by themselves while idle) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var box = root.querySelector(".ef-clip"), ul = box.querySelector(".ef-clip__list");
var opts = {}, rows = [], hoverRow = -1, demoTimer = 0, demoOff = 0, demoI = 0, io = null, visible = true, listeners = [];
box.classList.toggle("is-still", reduce);
function clips(row, x, y) {
var m = opts.mode, px = Math.round(x) + "%", py = Math.round(y) + "%";
if (m === "wipe") { row.style.setProperty("--c0", x < 50 ? "inset(0 100% 0 0)" : "inset(0 0 0 100%)"); row.style.setProperty("--c1", "inset(0 0 0 0)"); }
else if (m === "grow") { row.style.setProperty("--c0", "inset(50% 0 50% 0)"); row.style.setProperty("--c1", "inset(0 0 0 0)"); }
else { row.style.setProperty("--c0", "circle(0% at " + px + " " + py + ")"); row.style.setProperty("--c1", "circle(150% at " + px + " " + py + ")"); }
}
function open(i, x, y) { var r = rows[i]; clips(r, x, y); r.classList.add("is-on"); }
function close(i, x, y) { var r = rows[i]; clips(r, x, y); r.classList.remove("is-on"); }
function pct(e, r) {
var q = r.getBoundingClientRect();
return [Math.max(0, Math.min(100, ((e.clientX - q.left) / q.width) * 100)), Math.max(0, Math.min(100, ((e.clientY - q.top) / q.height) * 100))];
}
function demo() {
clearTimeout(demoTimer); demoTimer = 0;
if (reduce || !Number(opts.demo) || !rows.length || !visible) return;
demoTimer = setTimeout(function () {
if (hoverRow < 0) {
var i = demoI++ % rows.length;
open(i, 8, 50);
demoOff = setTimeout(function () { if (rows[i]) close(i, 92, 50); }, 1500);
}
demo();
}, 2800);
}
function build() {
listeners.forEach(function (l) { l[0].removeEventListener(l[1], l[2]); });
listeners = []; clearTimeout(demoOff);
ul.textContent = "";
rows = opts.items.split(",").map(function (s) { return s.trim(); }).filter(Boolean).map(function (label, i) {
var li = document.createElement("li"); li.className = "ef-clip__row"; li.tabIndex = 0;
function line(hidden) {
var t = document.createElement("span"); t.className = "ef-clip__t";
var a = document.createElement("span"); a.textContent = label;
var n = document.createElement("span"); n.className = "ef-clip__n"; n.textContent = String(i + 1).padStart(2, "0");
t.appendChild(a); t.appendChild(n); if (hidden) t.setAttribute("aria-hidden", "true"); return t;
}
var bg = document.createElement("div"); bg.className = "ef-clip__bg"; bg.setAttribute("data-p", String(i % 5)); bg.setAttribute("aria-hidden", "true");
bg.appendChild(line(true));
li.appendChild(line(false)); li.appendChild(bg); ul.appendChild(li);
var onEnter = function (e) { hoverRow = i; var p = pct(e, li); open(i, p[0], p[1]); };
var onLeave = function (e) { if (hoverRow === i) hoverRow = -1; var p = pct(e, li); close(i, p[0], p[1]); };
var onFocus = function () { open(i, 0, 50); };
var onBlur = function () { close(i, 100, 50); };
[["pointerenter", onEnter], ["pointerleave", onLeave], ["focus", onFocus], ["blur", onBlur]].forEach(function (p) { li.addEventListener(p[0], p[1]); listeners.push([li, p[0], p[1]]); });
return li;
});
}
function update(next) {
var prev = opts.items;
opts = Object.assign({ items: "Brand identity, Web design, Motion, Packaging, Editorial", mode: "circle", speed: 650, color: "#fbf8f1", accent: "#e6ff55", ink: "#0c1e18", demo: 1 }, opts, next || {});
box.style.setProperty("--ef-clip-c", opts.color);
box.style.setProperty("--ef-clip-a", opts.accent);
box.style.setProperty("--ef-clip-k", opts.ink);
box.style.setProperty("--ef-clip-d", Math.max(0, Number(opts.speed)) + "ms");
if (prev !== opts.items) build();
rows.forEach(function (r) { clips(r, 50, 50); });
demo();
}
update(o);
if ("IntersectionObserver" in window && !reduce) {
io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; demo(); });
io.observe(root);
}
return {
update: update,
destroy: function () {
clearTimeout(demoTimer); clearTimeout(demoOff);
if (io) io.disconnect();
listeners.forEach(function (l) { l[0].removeEventListener(l[1], l[2]); });
listeners = [];
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"items":"Brand identity, Web design, Motion, Packaging, Editorial","mode":"circle","speed":650,"color":"#fbf8f1","accent":"#e6ff55"});How it works
Every row holds its label twice. The first copy is normal text in the page colour. The second sits inside a layer that covers the row, has a pattern drawn with CSS gradients (stripes, dots, a soft ramp, a checker, fine lines, cycling by row) and is clipped away with clip-path. Hover or focus transitions the clip to fully open, so the pattern and the dark copy of the word appear together and the text appears to invert exactly along the moving edge. In circle mode the clip starts as a zero radius circle at the point where the pointer entered and collapses toward the point where it left. Wipe mode slides in from the side of entry, and grow mode opens from the middle line. Rows also open one at a time by themselves while idle so the effect shows without a pointer.
Accessibility and performance
Rows are focusable and keyboard focus opens the reveal from the left edge. The inverted copy is hidden from assistive technology, so the label is read once. The pattern is decorative and the dark ink on the light pattern keeps strong contrast with the defaults; check it if you recolour. With reduced motion the transition is instant and the idle demo is off.
Only clip-path changes, on one layer per row, and only while a row is transitioning. Patterns are pure CSS gradients with no images. There is no JavaScript loop; the idle demo is a single timer that pauses when the element is off screen.
| Section | Navigation and lists |
|---|---|
| Techniques | Pointer interaction, Reveal and intro |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Background Scale Hover Effect with CSS Clip-path by Manoela Ilic on Codrops. 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 images instead of gradients?
Yes. Replace the background rule on the clipped layer with an image. The clip transition and the inverted text work the same way.
Why two copies of the text?
A single element cannot be two colours split by an arbitrary moving edge without blend modes. A clipped second copy gives an exact inversion and works with any background.
How do the circle origins work?
The script reads where the pointer crossed the row edge, writes it into custom properties and the CSS clip-path reads them, so the circle grows from the real entry point.