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.