The Popover API: Zero-JS Mobile Navigation
The hamburger menu is a staple in the diet of mobile web navigation. Bad food puns aside - the classic implementation is click button, menu appears, click outside, menu closes. The classic stateful isOpen approach for React is so often reached for.
But now there’s a native browser approach: the Popover API recognised as Baseline and as of January 27 2025 available on all modern browsers: https://developer.mozilla.org/en-US/docs/Web/API/Popover_API. It handles show/hide, backdrop, focus trapping, and escape-to-close - all without JavaScript.
The Basic Pattern
<button popovertarget="menu">☰</button>
<nav id="menu" popover="auto">
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/blog">Blog</a>
</nav>
That’s it. Click the button, menu appears. Click outside or press Escape, it closes. The popover="auto" attribute gives you light-dismiss behaviour for free.
Making It Look Good
The raw popover works but looks as utilitarian as it gets. Here’s how to style it into a proper mobile menu the like of which I’ve used on this site:
.mobile-menu {
position: fixed;
inset: 0;
margin: auto;
width: 280px;
max-width: calc(100% - 3rem);
height: fit-content;
/* Glassmorphism */
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 1rem;
padding: 2rem;
border: 1px solid rgba(255, 255, 255, 0.2);
/* Animation setup */
opacity: 0;
transform: scale(0.85);
pointer-events: none;
transition:
opacity 0.3s ease,
transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.mobile-menu:popover-open {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
The Backdrop
Popovers support a ::backdrop pseudo-element:
.mobile-menu::backdrop {
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.3s ease;
}
.mobile-menu:popover-open::backdrop {
opacity: 1;
}
Entry Animations with @starting-style
The trick for smooth entry animations. @starting-style defines the initial state before the popover opens:
@starting-style {
.mobile-menu:popover-open {
opacity: 0;
transform: scale(0.85);
}
.mobile-menu:popover-open::backdrop {
opacity: 0;
}
}
Without this, the menu snaps open. With it, you get a smooth spring animation.
Respecting User Preferences
Always respect prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
.mobile-menu,
.mobile-menu::backdrop {
transition: opacity 0.15s ease;
}
.mobile-menu {
transform: scale(1);
}
}
Full Working Example
This is the actual code from this site’s mobile menu:
<button
class="mobile-menu-trigger"
popovertarget="mobile-menu"
popovertargetaction="toggle">
☰
</button>
<nav id="mobile-menu" popover="auto" class="mobile-menu">
<a href="/"><h1>mysite.dev</h1></a>
<div class="mobile-nav-links">
<a href="/stuff">🏗 stuff</a>
<a href="/words">💬 words</a>
<a href="/about">📖 about</a>
</div>
</nav>
.mobile-menu-trigger {
display: none;
position: fixed;
top: 1rem;
right: 1rem;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-color);
z-index: 100;
}
.mobile-menu {
position: fixed;
inset: 0;
margin: auto;
width: 280px;
max-width: calc(100% - 3rem);
height: fit-content;
background: var(--menu-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 1rem;
padding: 2rem;
border: 1px solid var(--menu-border);
z-index: 1000;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
opacity: 0;
transform: scale(0.85);
pointer-events: none;
transition:
opacity 0.3s ease,
transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275),
display 0.4s allow-discrete;
}
.mobile-menu::backdrop {
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.3s ease, display 0.3s allow-discrete;
}
.mobile-menu:popover-open {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.mobile-menu:popover-open::backdrop {
opacity: 1;
}
@starting-style {
.mobile-menu:popover-open {
opacity: 0;
transform: scale(0.85);
}
.mobile-menu:popover-open::backdrop {
opacity: 0;
}
}
.mobile-nav-links {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.mobile-nav-links a {
text-decoration: none;
color: var(--text-color);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
border: 1px solid transparent;
transition: background-color 0.2s ease;
}
.mobile-nav-links a:hover {
background-color: var(--menu-bg);
border-color: var(--menu-border);
}
@media (max-width: 768px) {
.mobile-menu-trigger {
display: block;
}
}
@media (prefers-reduced-motion: reduce) {
.mobile-menu, .mobile-menu::backdrop {
transition: opacity 0.15s ease;
}
.mobile-menu {
transform: scale(1);
}
}
Browser Support
Supported in all modern browsers (2024+):
- Chrome 114+
- Firefox 125+
- Safari 17+
For older browsers, consider a JS fallback or accept graceful degradation.
What You Get For Free
- Light dismiss - Click outside to close
- Escape key - Press Escape to close
- Focus management - Focus trapped inside
- Accessibility - Proper ARIA attributes automatic
- Top layer - No z-index wars
Popover vs Dialog
<dialog> is for modals demanding attention. popover is for menus and tooltips - less intrusive, light-dismiss built in.
Use dialog: Confirmations, forms, blocking interactions
Use popover: Menus, dropdowns, tooltips, non-blocking UI
TL;DR: The Popover API gives you toggle, backdrop, focus management, and escape-to-close with zero JavaScript. Add CSS for animations. Ship it.