Copied!
Navigation and lists

Clip reveal menu

Text rows that open a generated pattern behind the word with a clip-path transition on hover, while the word inverts along the edge.

← Library

Element · Vanilla JS + CSSUpdated November 5, 2026

CursorElementVanilla 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 "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.

SectionNavigation and lists
TechniquesPointer interaction, Reveal and intro
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byBackground 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.

Image compare on scrollA before and after slider whose divider is scrubbed by scroll position and can also be dragged or keyboard-operated. Unroll image on scrollA poster that unrolls like a sheet of paper from a spiral roll as you scroll or hover. Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side.

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.