Anchored Menus with Popover Entry Effects

Part of CSS Anchor Positioning & Motion in Modern View Transitions & Scroll APIs.

The problem

An account menu opens from an avatar button. The existing implementation toggles a class, positions the menu with a script, adds a document click listener for “click outside to close”, listens for Escape, and animates with a keyframe animation that restarts if the menu is toggled quickly. Closing the menu fades it out but the invisible menu keeps intercepting clicks for 200ms.

The platform now provides each of those pieces: popovers for open, close and light dismiss; anchor positioning for placement; transitions with allow-discrete for motion that does not trap clicks.

Root cause analysis: popover does the behaviour, CSS does the motion

popovertarget connects the button. A button with popovertarget="account-menu" toggles the popover with that id, with no script. A popover with popover="auto" closes when the user clicks outside it or presses Escape — light dismiss — and closes other auto popovers that are not its ancestors, which makes nested submenus work.

The button becomes the implicit anchor. When a popover is opened by an invoker such as popovertarget, the invoker acts as the popover’s implicit anchor in engines that support it, so position-area works without naming anything. Explicit anchor-name and position-anchor remain the portable choice.

Motion needs discrete properties in the transition. Closing a popover removes it from the top layer and sets display: none. Transitioning overlay and display with allow-discrete holds both until the exit finishes, and @starting-style defines the entrance, as in animating popover and dialog entry and exit.

The fading menu still receives clicks unless told not to. During the exit transition the popover is still rendered in the top layer, so it can intercept pointer events. pointer-events: none on the closed state removes it from hit-testing immediately while the fade continues.

Items should follow the surface. A light stagger on menu items — a few tens of milliseconds, capped — makes the menu feel choreographed, following choreographing multi-element motion. Items fade only; the surface does the scaling.

Opening the account menuBehaviour comes from the popover; placement and motion from CSS.Opening the account menuClick avatarpopovertargetTop layerpopover=autoAnchored placementposition-areaSurface entry@starting-styleItems fadecapped staggerFocus first item
Behaviour comes from the popover; placement and motion from CSS.

Step-by-step resolution

Assembling the menuDeclarative wiring first, motion second, focus last.Assembling the menu1Add popovertarget to the avatar button and popover=auto to the menu.Toggle, light dismiss and Escape2Anchor and place the menu below the button, end-aligned, with a flip fallback.Correct placement near edges3Transition opacity, scale, display and overlay; add pointer-events: none when closed.Smooth exit that never traps clicks4Fade items in with a delay of index times 25ms, capped at 100ms.Light choreography5On toggle to open, focus the first menu item.Keyboard users start inside the menu6Under reduced motion, fade the surface only.
Declarative wiring first, motion second, focus last.

Production code pattern

<button class="avatar" popovertarget="account-menu" aria-haspopup="menu" style="anchor-name: --avatar">
  <img src="me.jpg" alt="Account">
</button>
<div id="account-menu" class="menu" popover="auto" role="menu">
  <a role="menuitem" href="/profile" style="--i: 0">Profile</a>
  <a role="menuitem" href="/settings" style="--i: 1">Settings</a>
  <button role="menuitem" style="--i: 2">Sign out</button>
</div>
.menu {
  position: absolute;
  inset: auto;
  margin: 0;
  position-anchor: --avatar;
  position-area: block-end span-inline-start;
  margin-block-start: 8px;
  position-try-fallbacks: flip-block;
  transform-origin: center top;
  opacity: 0;
  scale: 0.96;
  pointer-events: none;                               /* never intercept clicks while closing */
  transition:
    opacity var(--motion-exit-duration) linear,
    scale var(--motion-exit-duration) var(--motion-exit-easing),
    display var(--motion-exit-duration) allow-discrete,
    overlay var(--motion-exit-duration) allow-discrete;
}
.menu:popover-open {
  opacity: 1;
  scale: 1;
  pointer-events: auto;
  transition:
    opacity var(--motion-enter-duration) linear,
    scale var(--motion-enter-duration) var(--motion-enter-easing),
    display var(--motion-enter-duration) allow-discrete,
    overlay var(--motion-enter-duration) allow-discrete;
  @starting-style { opacity: 0; scale: 0.96; }
}

.menu [role="menuitem"] {
  opacity: 1;
  transition: opacity var(--duration-200) linear min(calc(var(--i) * 25ms), 100ms);
}
.menu:popover-open [role="menuitem"] {
  @starting-style { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .menu, .menu:popover-open { scale: 1; }
  .menu [role="menuitem"] { transition: none; }
}
const menu = document.getElementById('account-menu');
menu.addEventListener('toggle', (e) => {
  if (e.newState === 'open') menu.querySelector('[role="menuitem"]')?.focus();
});

Rendering Impact: composite for the surface and item fades; layout once to position the menu. pointer-events changes hit-testing without repainting.

Starting the items’ opacity from their own @starting-style means they fade in each time the menu opens, because the whole menu leaves display: none. The item delay uses min() so a long menu does not keep users waiting for the last entries.

Lines of script for the same menuBehaviour that moved from script to platform features.Lines of script for the same menuScript: toggle, position, dismiss, Escape96 linespopover + anchor + CSS transitions4 lines
Behaviour that moved from script to platform features.

A submenu is another popover="auto" opened from an item inside the first menu. Because auto popovers only close popovers that are not their ancestors, opening a submenu keeps its parent open, and light dismiss closes both. Anchor the submenu to its item with position-area: inline-end span-block-end and flip-inline as a fallback, and use the same direction-neutral entrance so a flipped submenu still looks attached, as described in position-try fallbacks and motion. Keep submenu entrances shorter than the parent’s, and do not stagger their items; the second level should feel immediate.

Verification checklist

Constraints and trade-offs

  • Implicit anchors from invokers are newer than explicit anchor names; explicit names are more portable.
  • Full menu keyboard behaviour — arrow keys between items — still needs script.
  • role="menu" implies application-style keyboard handling; a simple list of links may be better as a disclosure.
  • Staggered items delay the moment every option is visible; keep the cap low.
  • Browsers without anchor positioning need a fallback placement rule.

Frequently asked questions

Do I need JavaScript to open and close a popover menu?

No. popovertarget on a button toggles the popover, and popover=auto handles outside clicks and Escape.

Why does my fading menu block clicks?

During the exit transition it is still rendered in the top layer. Set pointer-events: none on the closed state so it stops intercepting clicks immediately.

Does popovertarget create an anchor automatically?

In supporting browsers the invoker acts as the popover’s implicit anchor. Setting anchor-name and position-anchor explicitly works more widely.

Should menu items be staggered?

A light, capped stagger of about 25ms per item works well. Keep total delay around 100ms so the menu feels immediate.