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.
Step-by-step resolution
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-eventschanges 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.
Submenus
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.
Related
- CSS Anchor Positioning & Motion — the parent topic
- Animating Tooltips with Anchor Positioning — the non-interactive counterpart
- opacity vs visibility for Show and Hide Animations — why fading elements still receive input