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.
| Section | Navigation and lists |
|---|---|
| Techniques | Reveal and intro, Stagger |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Oyster 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.
More elements
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.