Premium mobile menu
A mobile menu is four things wired to one boolean: a button that morphs, a panel that reveals, the page behind it held still, and focus kept inside. Almost every broken menu is broken because those four fell out of sync — one of them is toggled somewhere else, by something else.
Drive all of it from a single set(state) function. No classList.toggle() scattered
across click handlers, no separate "close" path that forgets the scroll lock. One function,
every attribute, both directions.
Structure
The header outranks the panel, and the panel covers everything else:
.nav { position: fixed; inset: 0 0 auto; z-index: 210 } /* burger lives here */
.menu { position: fixed; inset: 0 0 auto; z-index: 200; height: 100dvh }
<header class="nav">
<a class="brand" href="/">silva</a>
<button class="burger" id="menuBtn" type="button"
aria-label="Open menu" aria-expanded="false" aria-controls="menu">
<i></i><i></i>
</button>
</header>
<div class="menu" id="menu" role="dialog" aria-label="Menu" tabindex="-1" inert>
<nav class="menu__nav">
<a href="#about"><span class="menu__idx">01</span> About</a>
<a href="#work"><span class="menu__idx">02</span> Work</a>
</nav>
<div class="menu__foot">…</div>
</div>
The burger stays in the header and morphs in place. It is the open control and the close control, which is why the header has to sit above the panel — see Trap 1.
The button
44×44 target, 22px of visible bar. The bars are absolutely positioned and centred with
margins, not transforms, so the transform property stays free for the morph:
.burger {
position: relative; display: grid; place-items: center;
width: 44px; height: 44px;
background: none; border: 0; padding: 0; cursor: pointer;
color: currentColor;
-webkit-tap-highlight-color: transparent;
}
.burger i {
position: absolute; left: 50%; top: 50%;
margin: -.75px 0 0 -11px; /* centring lives here, not in transform */
width: 22px; height: 1.5px;
background: currentColor; border-radius: 2px;
transition: transform .45s var(--ease);
}
.burger i:first-child { transform: translateY(-4px) }
.burger i:last-child { transform: translateY(4px) }
html.is-menu-open .burger i:first-child { transform: rotate(45deg) }
html.is-menu-open .burger i:last-child { transform: rotate(-45deg) }
Both bars share one centre point, so rotating them ±45° lands a symmetrical X with no
second keyframe and nothing to nudge. If you centre with translate(-50%,-50%) instead,
every morph state has to re-state that translate and the two bars drift apart by a pixel.
Use two bars, not three. The three-bar X needs the middle one to fade while the outer
two converge, and the fade is visible as a grey smear mid-transition. Two bars morph
cleanly. If a three-bar rest state is non-negotiable, animate the outer bars with
translateY + rotate and give the middle opacity a shorter duration so it is gone
before the rotation starts.
Never animate top/margin to move the bars. Transform only.
The panel
.menu {
position: fixed; left: 0; right: 0; top: 0;
height: 100dvh; /* not inset:0 — see Trap 2 */
z-index: 200;
display: flex; flex-direction: column; justify-content: space-between;
padding: clamp(96px, 24vw, 150px) var(--gutter)
max(clamp(28px, 7vw, 52px), env(safe-area-inset-bottom));
background: var(--black); color: var(--white);
overflow-y: auto; overscroll-behavior: contain;
opacity: 0; visibility: hidden; transform: translateY(-12px);
transition: opacity .45s var(--ease),
transform .55s var(--ease),
visibility 0s linear .45s; /* hide only after the fade finishes */
}
.menu.is-open {
opacity: 1; visibility: visible; transform: none;
transition-delay: 0s; /* cancel the visibility delay on the way in */
}
visibility is what makes this keyboard-safe: it removes the closed panel from the tab
order, which opacity: 0 alone does not. The delayed visibility transition is the whole
trick — visible immediately on open, hidden only once the fade-out has played.
The staggered link reveal is what reads as expensive:
.menu__nav a {
opacity: 0; transform: translateY(18px);
transition: opacity .6s var(--ease), transform .6s var(--ease);
}
.menu.is-open .menu__nav a { opacity: 1; transform: none }
.menu.is-open .menu__nav a:nth-child(1) { transition-delay: .10s }
.menu.is-open .menu__nav a:nth-child(2) { transition-delay: .16s }
.menu.is-open .menu__nav a:nth-child(3) { transition-delay: .22s }
.menu.is-open .menu__nav a:nth-child(4) { transition-delay: .28s }
.menu.is-open .menu__nav a:nth-child(5) { transition-delay: .34s }
60ms apart. Below ~40ms it reads as one block; above ~90ms the last item feels late.
For a list of unknown length, put style="--i:3" on each item and use
transition-delay: calc(.10s + var(--i) * .06s).
On the way out, drop the delays (.menu:not(.is-open) .menu__nav a { transition-delay: 0s }).
A staggered exit makes closing feel slow — closing should be immediate.
The state function
(function () {
var btn = document.getElementById('menuBtn');
var panel = document.getElementById('menu');
var root = document.documentElement;
var open = false;
var scrollY = 0;
var FOCUSABLE = 'a[href], button:not([disabled]), input, select, textarea,' +
'[tabindex]:not([tabindex="-1"])';
// the burger is the close control and lives outside the panel, so it belongs in the cycle
function focusables() {
return [btn].concat([].slice.call(panel.querySelectorAll(FOCUSABLE)));
}
function set(state) {
if (state === open) return;
open = state;
panel.classList.toggle('is-open', open);
root.classList.toggle('is-menu-open', open);
btn.setAttribute('aria-expanded', String(open));
btn.setAttribute('aria-label', open ? 'Close menu' : 'Open menu');
if (open) panel.removeAttribute('inert');
else panel.setAttribute('inert', '');
if (open) {
scrollY = window.scrollY;
document.body.style.position = 'fixed';
document.body.style.top = -scrollY + 'px';
document.body.style.left = '0';
document.body.style.right = '0';
panel.focus({ preventScroll: true });
} else {
document.body.style.position = '';
document.body.style.top = '';
document.body.style.left = '';
document.body.style.right = '';
window.scrollTo(0, scrollY);
btn.focus({ preventScroll: true });
}
}
btn.addEventListener('click', function () { set(!open); });
panel.querySelectorAll('a').forEach(function (a) {
a.addEventListener('click', function () { set(false); });
});
document.addEventListener('keydown', function (e) {
if (!open) return;
if (e.key === 'Escape') { set(false); return; }
if (e.key !== 'Tab') return;
var list = focusables();
var first = list[0];
var last = list[list.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
// crossing the breakpoint must not strand the page in a locked state
matchMedia('(min-width: 1101px)').addEventListener('change', function (e) {
if (e.matches) set(false);
});
})();
inert replaces aria-hidden here. Never put aria-hidden="true" on a panel that can
hold focus — if focus is inside it, you have hidden the focused element from the
accessibility tree, which is an authoring error screen readers handle badly. inert blocks
focus and hides from the tree in one attribute.
Note the role="dialog" without aria-modal="true". aria-modal tells assistive tech
to ignore everything outside the dialog, and the close button is outside it. Either drop
aria-modal (as above) or put a real close button inside the panel.
Trap 1 — the header must outrank the panel
The burger morphs into the X, so it has to stay clickable while the panel is up. If the
panel's z-index is higher than the header's, the X renders (it is painted in the header)
but every click lands on the overlay. The menu becomes a trap with no visible way out —
reported as "the close button does nothing."
Give the header a higher z-index than the panel and check both against every other fixed
layer on the page (cookie bars and sticky CTAs are the usual collisions). A worked set:
panel 200, header 210, toasts 9000.
Fixed elements only stack against each other when they share a stacking context. A
transform, filter, opacity < 1, or will-change on any ancestor creates a new one
and silently re-parents the whole comparison.
Trap 2 — overflow: hidden on body does not hold iOS
document.body.style.overflow = 'hidden' locks desktop browsers and does nothing to a
touch drag on iOS Safari. The page rubber-bands behind the panel, and on release the user
is somewhere else entirely.
The lock that works is pinning the body and restoring the offset, which is what set()
above does. Two details that go with it:
overscroll-behavior: containon the panel stops the panel's own scroll from chaining to the page when it hits an end.- Restore with
window.scrollTo(0, scrollY)in the same call that clearsposition. ReadingscrollYafter the body is unpinned gives you 0.
If your header is position: sticky rather than fixed, this lock breaks it. A pinned
body has no scrollport, so sticky resolves against the document and the header jumps to the
top of the page — off screen, taking the burger with it. Switch it to fixed while the
menu is open (html.is-menu-open .nav { position: fixed; top: 0 }). The lenis-smooth-scroll
skill covers the same failure from the Lenis side; if that library is on the page, use
lenis.stop() instead of a second lock rather than stacking both.
Test the lock at a non-zero scroll offset. At scroll 0 a broken lock is pixel-identical to a working one. Scroll to ~1200, open, drag, close: the position must read 1200 throughout.
Trap 3 — display: none cannot be transitioned
Toggling display skips the animation entirely: the panel snaps in and snaps out. The
visibility + delayed-transition pattern above is the version that works everywhere.
The modern equivalent, if you are willing to require a 2024+ baseline:
.menu { display: none; opacity: 0;
transition: opacity .45s var(--ease), display .45s allow-discrete }
.menu.is-open { display: flex; opacity: 1 }
@starting-style { .menu.is-open { opacity: 0 } }
@starting-style is required — without it the entry has no start value and only the exit
animates.
Trap 4 — the panel is the wrong place for a backdrop blur
backdrop-filter on a full-screen panel re-samples everything behind it every frame, and
on mid-range Android it drops the entrance animation to single-digit fps. On one build,
removing a full-viewport blend/blur took dropped frames from 34% to 3%.
A premium menu is almost always an opaque brand-coloured panel, not frosted glass. If
the design calls for glass, blur a small element (a sheet that covers part of the screen),
never the full viewport, and see the liquid-glass skill.
Trap 5 — the states nobody tests
- Resize past the breakpoint while open. Handled above with
matchMedia, not aresizelistener, so it fires once on the crossing instead of on every frame of a drag. - Client-side route change. In Next/Astro/SPA navigation the panel survives the
navigation. Call
set(false)on the router event, and clean up the body styles on unmount. - Back button after opening. If you push a history entry when the menu opens, the back button must close it rather than leaving the page.
- Zoom to 200%. The panel must scroll rather than clip its footer.
- Landscape phone.
100dvhwith a scrollable panel handles it; a vertically centredjustify-content: centerlist does not.
Reduced motion
@media (prefers-reduced-motion: reduce) {
.menu, .menu__nav a, .burger i {
transition-duration: .01ms !important;
transition-delay: 0s !important;
}
}
Keep the state changes, drop the choreography. The panel must still appear and disappear.
What makes it read as premium
The mechanics above are table stakes. These are the choices that separate it from a bootstrap drawer:
- Easing.
cubic-bezier(.16, 1, .3, 1)(expo-out) on the panel and links. Fast departure, long settle. Nevereaseorlinear. - Timing. Panel 450–600ms, links 600ms on a 60ms stagger, burger morph 450ms. Under 300ms nothing registers as motion; over 700ms it feels sluggish.
- Type. The link list is the design.
clamp(1.65rem, 8.4vw, 2.9rem), display weight 300,line-height: 1, uppercase, tight tracking. Hairline rules between rows. - Index numerals.
01 / 02 / 03in small mono or letterspaced caps before each label, set in a muted tone. Cheap to add, does more for the perceived quality than any effect. - Full-row hit areas. Vertical padding of
clamp(14px, 3.4vw, 22px)on the anchor itself, not a margin around it, so the whole row is tappable. - An
:activestate, not a:hoverstate. Phones do not hover, and a hover style that sticks after a tap looks broken. - A footer block — contact line, socials, a single pill CTA — pinned by
justify-content: space-between. Empty bottom space is what makes a menu feel like a placeholder. - No bounce or spring on the panel itself. Overshoot on a full-screen surface reads as cheap. Save spring easing for small elements.
Checklist
- One
set(state)function owns class,aria-expanded, label,inert, and scroll lock - Header
z-indexis above the panel's, verified against every other fixed layer - Burger is 44×44 with the bars centred by margin, morphing on
transformonly - Panel uses
visibility(orallow-discrete+@starting-style), never baredisplay -
height: 100dvh,overscroll-behavior: contain,env(safe-area-inset-bottom)padding - Body pinned with the offset restored — tested at a non-zero scroll position on iOS
- Sticky header switched to
fixedwhile locked, or no second scroll lock in play - Tab cycles burger → panel → burger; Escape closes and returns focus to the burger
-
inertwhen closed, noaria-hiddenon a focusable panel, no orphanaria-modal - Links close the menu;
matchMediacloses it past the breakpoint; SPA routes clean up - Stagger on entry only, no stagger on exit
-
prefers-reduced-motionkeeps the state change, drops the choreography - No
backdrop-filteron the full-viewport panel