Copied!
Navigation and lists

Expanding rounded menu

A round menu button that grows into a full rounded panel with a clip-path reveal and staggered links, with Esc, outside-click and a focus trap.

← Library

Element · Vanilla JS + CSSUpdated November 6, 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 "Expanding rounded menu" for my site using the Enrichr Library element at https://enrichr.dev/elements/expanding-rounded-menu.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/expanding-rounded-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
- background: #0c1e18
- duration: 650
- stagger: 70
- radius: 1.6

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/expanding-rounded-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">
  <div class="ef-erm__stage">
    <p class="ef-erm__hint">Menu</p>
    <div class="ef-erm__panel" id="ef-erm-panel" role="dialog" aria-label="Site menu"><nav class="ef-erm__nav"></nav></div>
    <button class="ef-erm__btn" type="button" aria-expanded="false" aria-controls="ef-erm-panel" aria-label="Open menu"><span class="ef-erm__bar"></span><span class="ef-erm__bar"></span></button>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-erm { width: 100%; }
.ef-erm__stage { --bx: 2.4rem; --by: 2.4rem; --dur: 650ms; --rad: 1.6rem; position: relative; box-sizing: border-box; width: 100%; min-height: 22rem; overflow: hidden; border-radius: 1rem; background: #0c1e18; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #fbf8f1; }
.ef-erm__hint { position: absolute; left: 1.4rem; top: 1.5rem; margin: 0; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; opacity: 0.6; }
.ef-erm__panel { position: absolute; inset: 0.7rem; border-radius: var(--rad); background: linear-gradient(155deg, #0a6b46, #005236 55%, #0c1e18); box-shadow: inset 0 0 0 1px rgba(230, 255, 85, 0.18); clip-path: circle(1.6rem at calc(100% - var(--bx) + 0.7rem) calc(var(--by) - 0.7rem)); visibility: hidden; transition: clip-path var(--dur) cubic-bezier(.7,0,.2,1), visibility 0s linear var(--dur); }
.ef-erm.is-open .ef-erm__panel { clip-path: circle(150% at calc(100% - var(--bx) + 0.7rem) calc(var(--by) - 0.7rem)); visibility: visible; transition: clip-path var(--dur) cubic-bezier(.7,0,.2,1), visibility 0s; }
.ef-erm__nav { box-sizing: border-box; height: 100%; padding: 4.4rem 1.6rem 1.4rem; display: flex; flex-direction: column; justify-content: center; gap: 0.15rem; }
.ef-erm__link { display: flex; align-items: baseline; gap: 0.8rem; padding: 0.35rem 0; color: #fbf8f1; text-decoration: none; font-size: clamp(1.4rem, 5vw, 2rem); font-weight: 800; letter-spacing: -0.02em; line-height: 1.1; opacity: 0; transform: translateY(1.2rem); transition: opacity 300ms ease, transform 400ms cubic-bezier(.2,.8,.2,1), color 200ms; transition-delay: 0ms; border-radius: 0.5rem; outline: none; }
.ef-erm.is-open .ef-erm__link { opacity: 1; transform: none; transition-delay: calc(var(--dur) * 0.45 + var(--i) * var(--stg, 70ms)); }
.ef-erm__num { font-size: 0.68rem; font-weight: 700; letter-spacing: 0.14em; color: var(--ef-acc, #e6ff55); }
.ef-erm__link:hover, .ef-erm__link:focus-visible { color: var(--ef-acc, #e6ff55); }
.ef-erm__link:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc, #e6ff55); padding-left: 0.4rem; }
.ef-erm__btn { position: absolute; top: calc(var(--by) - 1.7rem); right: calc(var(--bx) - 1.7rem); z-index: 2; width: 3.4rem; height: 3.4rem; border: 0; border-radius: 50%; cursor: pointer; background: var(--ef-acc, #e6ff55); display: grid; place-content: center; gap: 0.32rem; transition: transform 300ms cubic-bezier(.2,.8,.2,1), box-shadow 200ms; outline: none; }
.ef-erm__btn:hover { transform: scale(1.06); }
.ef-erm__btn:focus-visible { box-shadow: 0 0 0 3px #0c1e18, 0 0 0 5px var(--ef-acc, #e6ff55); }
.ef-erm__bar { display: block; width: 1.3rem; height: 2px; background: #0c1e18; border-radius: 2px; transition: transform 350ms cubic-bezier(.2,.8,.2,1); }
.ef-erm.is-open .ef-erm__bar:nth-child(1) { transform: translateY(0.19rem) rotate(45deg); }
.ef-erm.is-open .ef-erm__bar:nth-child(2) { transform: translateY(-0.19rem) rotate(-45deg); }
@media (prefers-reduced-motion: reduce) {
  .ef-erm__panel, .ef-erm.is-open .ef-erm__panel { transition: none; }
  .ef-erm__link, .ef-erm.is-open .ef-erm__link { transition: none; transform: none; }
  .ef-erm__bar, .ef-erm__btn { transition: none; }
}

JavaScript

// Enrichr Library · https://enrichr.dev
// Expanding rounded menu: a round button grows a clipped, rounded panel from its own centre and staggers the links in.
// The reveal is one clip-path circle transition; links get a --i index for the stagger. Esc or an outside press closes it, Tab is trapped
// inside while open and focus returns to the button. Reduced motion skips the transitions and just shows the panel.
// mount(root, { background, accent, duration (ms), stagger (ms), radius (rem) })
function mount(root, o) {
  var host = root.classList.contains("ef-erm") ? root : root.querySelector(".ef-erm");
  var stage = root.querySelector(".ef-erm__stage");
  var btn = root.querySelector(".ef-erm__btn");
  var nav = root.querySelector(".ef-erm__nav");
  var opts = {}, links = [], built = false, openState = false;
  var ITEMS = ["Work", "Studio", "Journal", "Shop", "Contact"];

  function build() {
    ITEMS.forEach(function (name, i) {
      var a = document.createElement("a");
      a.className = "ef-erm__link";
      a.href = "#";
      a.style.setProperty("--i", String(i));
      var n = document.createElement("span"); n.className = "ef-erm__num"; n.textContent = String(i + 1).padStart(2, "0");
      var t = document.createElement("span"); t.textContent = name;
      a.appendChild(n); a.appendChild(t);
      a.addEventListener("click", onLink);
      nav.appendChild(a);
      links.push(a);
    });
    built = true;
  }
  function onLink(e) { e.preventDefault(); setOpen(false, true); }
  function setOpen(v, refocus) {
    if (v === openState) return;
    openState = v;
    host.classList.toggle("is-open", v);
    btn.setAttribute("aria-expanded", v ? "true" : "false");
    btn.setAttribute("aria-label", v ? "Close menu" : "Open menu");
    if (v) { if (links[0]) links[0].focus({ preventScroll: true }); }
    else if (refocus) btn.focus({ preventScroll: true });
  }
  function onBtn() { setOpen(!openState, true); }
  function onKey(e) {
    if (!openState) return;
    if (e.key === "Escape") { e.preventDefault(); setOpen(false, true); return; }
    if (e.key !== "Tab") return;
    var seq = [btn].concat(links);
    var i = seq.indexOf(document.activeElement);
    if (i === -1) { e.preventDefault(); seq[0].focus(); return; }
    e.preventDefault();
    var next = (i + (e.shiftKey ? -1 : 1) + seq.length) % seq.length;
    seq[next].focus();
  }
  function onDown(e) {
    if (!openState) return;
    if (!nav.contains(e.target) && !btn.contains(e.target)) setOpen(false, false);
  }
  btn.addEventListener("click", onBtn);
  document.addEventListener("keydown", onKey);
  document.addEventListener("pointerdown", onDown);
  build();

  function update(next) {
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", duration: 650, stagger: 70, radius: 1.6 }, opts, next || {});
    stage.style.background = opts.background;
    stage.style.setProperty("--ef-acc", opts.accent);
    stage.style.setProperty("--dur", opts.duration + "ms");
    stage.style.setProperty("--stg", opts.stagger + "ms");
    stage.style.setProperty("--rad", opts.radius + "rem");
  }
  update(o);
  return {
    update: update,
    destroy: function () {
      btn.removeEventListener("click", onBtn);
      document.removeEventListener("keydown", onKey);
      document.removeEventListener("pointerdown", onDown);
      links.forEach(function (a) { a.removeEventListener("click", onLink); });
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","background":"#0c1e18","duration":650,"stagger":70,"radius":1.6});

How it works

The panel is always laid out at full size but clipped with clip-path: circle() centred on the button. Opening animates the circle radius to 150%, so the panel appears to grow out of the button, and its rounded corners come from border-radius on the panel itself. Each link has an index in --i and its transition-delay is calculated from it plus the open duration, which gives the stagger. The script only toggles one is-open class, updates aria-expanded, and handles Esc, outside presses and Tab wrapping.

Accessibility and performance

The button reports aria-expanded and swaps its label between open and close. While open, focus moves to the first link, Tab and Shift+Tab wrap between the button and the links, and Esc closes and returns focus to the button. The closed panel is visibility hidden so its links cannot be tabbed to. Reduced motion removes every transition and shows or hides the panel instantly.

Only clip-path, transform and opacity change. The panel is one element with a handful of links. Avoid very large blurred shadows on the panel while it animates.

SectionNavigation and lists
TechniquesPointer interaction, Stagger, Reveal and intro
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byExpanding Rounded Menu Animation by Codrops 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 trail introAn intro where gradient tiles are emitted along the pointer, or along a looping path when idle, stack and fade, and then the headline slides in. Glitchy grid layout slideshowA grid of puzzle-piece tiles that drift against the pointer. Switching layouts sends the tiles out, then stacks the next set in the centre and settles them into the grid, and a magnetic button fires a clipped glitch. Stagger letter swapClick to swap two screens of big type: every character slides up and out in a stagger, the background shifts colour, and the next text rises into place, with a soft cursor circle following the mouse.

FAQ

Why clip-path instead of scaling a circle?

Clip-path reveals the panel at its final size, so text never gets squashed or reflowed during the animation.

Does it need a fixed container?

No. It is positioned inside its own stage and works inside overflow hidden containers. For a full page menu, make the stage the viewport size.

How do I change the origin of the reveal?

Change --bx and --by on the stage, which set where the button and the circle centre sit.