Copied!
Navigation and lists

Island menu

A round menu button that stretches into a wide pill, spins its logo in, turns its bars into a cross and pops a panel of links out of the corner.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

Navigation and listsElementVanilla 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 "Island menu" for my site using the Enrichr Library element at https://enrichr.dev/elements/island-menu.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/island-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:
- brand: Enrichr
- items: About, Work, Journal, Contact
- accent: #0ae448
- background: #0e100f
- width: 320

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/island-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-ism"><div class="ef-ism__zone"><header class="ef-ism__bar"><span class="ef-ism__name"></span><div class="ef-ism__island"><span class="ef-ism__logo" aria-hidden="true"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 2.5 21.5 12 12 21.5 2.5 12Z"/><path d="M12 7.5 16.5 12 12 16.5 7.5 12Z"/></svg></span><span class="ef-ism__brand" aria-hidden="true"></span><button type="button" class="ef-ism__btn" aria-expanded="false" aria-label="Open navigation menu"><svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" aria-hidden="true"><line class="ef-ism__ln ef-ism__ln--t" x1="2" y1="4" x2="14" y2="4"/><line class="ef-ism__ln ef-ism__ln--m" x1="2" y1="8" x2="14" y2="8"/><line class="ef-ism__ln ef-ism__ln--b" x1="2" y1="12" x2="14" y2="12"/></svg></button></div></header><div class="ef-ism__backdrop"></div><nav class="ef-ism__panel" aria-label="Main"><ul class="ef-ism__links"></ul></nav><div class="ef-ism__page"><h2 class="ef-ism__h">Open the island</h2><p>The menu button grows into a wide pill, then the panel drops out of it.</p></div></div></div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-ism__zone { position: relative; overflow: hidden; min-height: 26rem; border-radius: 1rem; color: #f3f1ea; background: var(--ef-bg, #0e100f); container-type: inline-size; --ef-back: cubic-bezier(0.34, 1.56, 0.64, 1); }
.ef-ism__bar { position: relative; z-index: 3; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1.1rem clamp(1rem, 4vw, 2rem); }
.ef-ism__name { font-size: 0.8rem; font-weight: 700; letter-spacing: 0.22em; text-transform: uppercase; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ef-ism__island { position: relative; display: flex; align-items: center; justify-content: flex-end; flex-shrink: 0; box-sizing: border-box; width: 50px; height: 50px; padding: 6px; border-radius: 999px; border: 1.5px solid rgba(255, 255, 255, 0.1); background: rgba(22, 24, 22, 0.92); overflow: hidden; transition: width 0.8s var(--ef-back), border-color 0.3s ease; }
.ef-ism__island:hover, .ef-ism.is-open .ef-ism__island { border-color: color-mix(in srgb, var(--ef-accent, #0ae448) 40%, transparent); }
.ef-ism.is-open .ef-ism__island { width: var(--ef-open, 320px); }
.ef-ism__logo { position: absolute; left: 12px; top: 50%; display: flex; color: var(--ef-accent, #0ae448); opacity: 0; transform: translateY(-50%) rotate(-180deg); transition: opacity 0.3s ease, transform 0.5s var(--ef-back); }
.ef-ism__brand { position: absolute; left: 44px; top: 50%; transform: translateY(-50%); font-size: 0.85rem; font-weight: 700; white-space: nowrap; opacity: 0; transition: opacity 0.2s ease; }
.ef-ism.is-open .ef-ism__logo { opacity: 1; transform: translateY(-50%) rotate(0deg); transition-delay: 0.12s; }
.ef-ism.is-open .ef-ism__brand { opacity: 1; transition: opacity 0.4s ease 0.3s; }
.ef-ism__btn { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 36px; height: 36px; margin: 0; padding: 0; border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 50%; background: rgba(255, 255, 255, 0.03); color: #bbbaa6; cursor: pointer; transition: background 0.2s, transform 0.2s; }
.ef-ism__btn:hover { background: color-mix(in srgb, var(--ef-accent, #0ae448) 15%, transparent); transform: scale(1.05); }
.ef-ism__btn:focus-visible { outline: 2px solid var(--ef-accent, #0ae448); outline-offset: 3px; }
.ef-ism__ln { transform-box: view-box; transform-origin: 8px 8px; transition: transform 0.28s cubic-bezier(0.65, 0, 0.35, 1), opacity 0.15s ease; }
.ef-ism.is-open .ef-ism__ln--t { transform: translateY(4px) rotate(45deg) scaleX(1.18); }
.ef-ism.is-open .ef-ism__ln--m { opacity: 0; }
.ef-ism.is-open .ef-ism__ln--b { transform: translateY(-4px) rotate(-45deg) scaleX(1.18); }
.ef-ism__backdrop { position: absolute; inset: 0; z-index: 2; background: rgba(14, 16, 15, 0.82); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); opacity: 0; pointer-events: none; transition: opacity 0.3s ease; }
.ef-ism.is-open .ef-ism__backdrop { opacity: 1; pointer-events: auto; }
.ef-ism__panel { position: absolute; z-index: 3; top: 4.9rem; right: clamp(1rem, 4vw, 2rem); width: min(calc(100% - 2rem), var(--ef-open, 320px)); box-sizing: border-box; padding: 0.6rem; border: 1.5px solid rgba(255, 255, 255, 0.12); border-radius: 20px; background: rgba(22, 24, 22, 0.98); box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.8), 0 0 30px color-mix(in srgb, var(--ef-accent, #0ae448) 6%, transparent); transform-origin: top right; opacity: 0; visibility: hidden; transform: translateY(-10%) scale(0.6); transition: opacity 0.3s ease, transform 0.5s ease, visibility 0s linear 0.5s; }
.ef-ism.is-open .ef-ism__panel { opacity: 1; visibility: visible; transform: none; transition: opacity 0.3s ease 0.1s, transform 0.8s var(--ef-back) 0.1s, visibility 0s; }
.ef-ism .ef-ism__links { margin: 0; padding: 0; list-style: none; }
.ef-ism .ef-ism__links li { margin: 0; opacity: 1; }
.ef-ism__link { display: flex; align-items: center; justify-content: space-between; padding: 0.95rem 1.1rem; border-radius: 12px; color: inherit; font-size: 1.05rem; font-weight: 600; text-decoration: none; opacity: 0; transform: translateY(6px); transition: opacity 0.2s ease, transform 0.2s ease, background 0.2s ease, color 0.2s ease; }
.ef-ism.is-open .ef-ism__link { opacity: 1; transform: none; transition: opacity 0.32s ease calc(0.22s + var(--i, 0) * 0.05s), transform 0.32s ease calc(0.22s + var(--i, 0) * 0.05s), background 0.2s ease, color 0.2s ease; }
.ef-ism__link:hover, .ef-ism__link:focus-visible { background: color-mix(in srgb, var(--ef-accent, #0ae448) 12%, transparent); color: var(--ef-accent, #0ae448); outline: none; }
.ef-ism__num { font-size: 0.75rem; font-weight: 500; opacity: 0.5; font-variant-numeric: tabular-nums; }
.ef-ism__page { position: relative; z-index: 1; padding: clamp(2rem, 7vw, 4.5rem) clamp(1rem, 4vw, 2rem) 2.5rem; max-width: 34rem; }
.ef-ism .ef-ism__h { margin: 0 0 0.8rem; font-size: clamp(2rem, 7vw, 4rem); font-size: clamp(2rem, 9cqi, 4rem); font-weight: 800; line-height: 1; letter-spacing: -0.03em; }
.ef-ism .ef-ism__page p { margin: 0; opacity: 1; color: rgba(243, 241, 234, 0.65); font-size: 1.05rem; line-height: 1.5; }
@media (prefers-reduced-motion: reduce) {
  .ef-ism__zone *, .ef-ism__zone *::before { transition-duration: 0s !important; transition-delay: 0s !important; }
}

JavaScript

// Enrichr Library · https://enrichr.dev
// Island menu: a round menu button in the header that stretches into a wide "dynamic island" pill with an overshoot,
// spins a logo in, morphs its three bars into a cross, blurs the page and pops a panel of links out of the corner.
// Escape, a click on the page or picking a link closes it; focus moves into the links and back to the button.
// mount(root, { brand, items (comma separated), accent, background, width (px of the open island) })
function mount(root, o) {
  var wrap = root.querySelector(".ef-ism"), zone = root.querySelector(".ef-ism__zone");
  var btn = root.querySelector(".ef-ism__btn"), panel = root.querySelector(".ef-ism__panel");
  var list = root.querySelector(".ef-ism__links"), backdrop = root.querySelector(".ef-ism__backdrop");
  var name = root.querySelector(".ef-ism__name"), brand = root.querySelector(".ef-ism__brand");
  var pid = "ef-ism-" + Math.random().toString(36).slice(2, 8);
  var opts = {}, open = false, links = [];
  panel.id = pid;
  btn.setAttribute("aria-controls", pid);

  function set(next, focus) {
    open = next;
    wrap.classList.toggle("is-open", open);
    btn.setAttribute("aria-expanded", String(open));
    btn.setAttribute("aria-label", open ? "Close navigation menu" : "Open navigation menu");
    if (open && focus && links[0]) setTimeout(function () { if (open) links[0].focus({ preventScroll: true }); }, 60);
    if (!open && focus) btn.focus({ preventScroll: true });
  }
  function toggle(e) { set(!open, e.detail === 0); }
  function onKey(e) {
    if (!open) return;
    if (e.key === "Escape") { e.preventDefault(); set(false, true); return; }
    if (e.key !== "Tab") return;
    var ring = [btn].concat(links), i = ring.indexOf(document.activeElement);
    if (i < 0) return;
    if (e.shiftKey && i === 0) { e.preventDefault(); ring[ring.length - 1].focus(); }
    else if (!e.shiftKey && i === ring.length - 1) { e.preventDefault(); ring[0].focus(); }
  }
  function closeFromPage() { if (open) set(false, false); }
  function pick(e) { e.preventDefault(); set(false, true); }
  function fit() {
    var max = zone.clientWidth - 2 * parseFloat(getComputedStyle(root.querySelector(".ef-ism__bar")).paddingLeft || 0);
    wrap.style.setProperty("--ef-open", Math.max(50, Math.min(Number(opts.width), max)) + "px");
  }
  function update(next) {
    opts = Object.assign({ brand: "Enrichr", items: "About, Work, Journal, Contact", accent: "#0ae448", background: "#0e100f", width: 320 }, opts, next || {});
    name.textContent = opts.brand;
    brand.textContent = opts.brand;
    wrap.style.setProperty("--ef-accent", opts.accent);
    zone.style.setProperty("--ef-bg", opts.background);
    list.textContent = "";
    links = opts.items.split(",").map(function (s) { return s.trim(); }).filter(Boolean).map(function (label, i) {
      var li = document.createElement("li"), a = document.createElement("a"), t = document.createElement("span"), n = document.createElement("span");
      a.className = "ef-ism__link"; a.href = "#"; a.style.setProperty("--i", i);
      t.textContent = label;
      n.className = "ef-ism__num"; n.textContent = (i < 9 ? "0" : "") + (i + 1);
      a.appendChild(t); a.appendChild(n); li.appendChild(a); list.appendChild(li);
      a.addEventListener("click", pick);
      return a;
    });
    fit();
  }
  update(o);
  btn.addEventListener("click", toggle);
  backdrop.addEventListener("click", closeFromPage);
  zone.addEventListener("keydown", onKey);
  window.addEventListener("resize", fit);
  return {
    update: update,
    destroy: function () {
      btn.removeEventListener("click", toggle);
      backdrop.removeEventListener("click", closeFromPage);
      zone.removeEventListener("keydown", onKey);
      window.removeEventListener("resize", fit);
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"brand":"Enrichr","items":"About, Work, Journal, Contact","accent":"#0ae448","background":"#0e100f","width":320});

How it works

Everything is CSS transitions switched by one is-open class; the script only toggles the class and handles focus. The island's width transitions from a 50px circle to the open width with a back-out curve (cubic-bezier 0.34, 1.56, 0.64, 1), so it overshoots and settles. The logo fades in while turning from -180 degrees, and the brand name fades in after it. The three hamburger lines are SVG lines: the top and bottom ones move to the middle, rotate 45 degrees in opposite directions and stretch slightly to become a cross, and the middle one fades out. A blurred backdrop fades in over the page. The panel starts at 60 percent scale, nudged up, invisible, with its origin in the top right corner, and grows out with the same overshoot. Each link fades and rises in after a delay of 0.22 seconds plus 0.05 seconds times its position.

Accessibility and performance

The toggle is a real button with aria-expanded, aria-controls and a label that switches between open and close. Opening from the keyboard moves focus to the first link, Tab cycles between the button and the links while it is open, and Escape closes it and returns focus to the button. A click on the blurred page or on a link also closes it. Closed links are hidden with visibility, so they are out of the tab order. With reduced motion every transition is instant.

No animation loop at all: every change is a CSS transition on transform, opacity or width. The width transition does trigger layout on the island only, which is small. The backdrop blur costs a little while open; drop backdrop-filter if the page behind is heavy.

SectionNavigation and lists
TechniquesReveal and intro, Stagger
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byOyster Mushrooms Co. by Homeless Romantic Creative on CodePen. 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.

Rapid layers introA stack of full-bleed colour layers wipes in quickly one after another, then wipes away to reveal a headline, with a replay button. Staggered grid revealA grid of tiles that flip up one after another in random, reading, centre-out or edges-in order, then spin their glyphs into place. Split text revealA headline whose words rise out of a mask one after another when it scrolls into view.

FAQ

Can it be fixed to the top of a real page?

Yes. Make the bar position: fixed with a high z-index, and make the backdrop and panel position: fixed too so they cover the viewport rather than the demo area.

Does it need GSAP?

No. The original used a GSAP timeline; the same sequence here is CSS transitions with delays, so there is nothing to load.

How do I change the timing?

The island width uses 0.8 seconds, the panel 0.8 seconds after a 0.1 second delay, and the links 0.32 seconds each with a 0.05 second stagger. Edit those numbers in the CSS.