On a Centra fashion storefront, the desktop mega menu lives under a sticky header. A shopper hits Escape after browsing Women, and the dim layer over the page vanishes while the flyout is still sliding shut. For a beat the header looks hollow and a thin dark seam shows between chrome and panel. QA filed it as a flicker. I had wired backdrop visibility to the same controlled value I passed into Base UI Navigation Menu.
The panel is portaled full-bleed under the header. Listings and promos sit inside. While any primary nav flyout is open, a dim layer covers everything below the header shell. Merchandisers expect that layer to feel glued to the menu. When it snaps off early, the store looks broken even though the popup is technically still animating out.
Base UI clears value when exit starts
I controlled the menu with value and onValueChange. Backdrop data-open, header background, and the portaled popup all read that one string. Radix habits die hard: I assumed "value is null" meant "menu is closed." Base UI does not wait for the close animation to finish before it fires onValueChange(null).
That callback lands when the exit animation starts. My Tailwind stack used invisible plus opacity transitions on the dim. Opacity animates. visibility: hidden does not. The moment value cleared, the backdrop jumped to invisible while the popup still had 350ms of translate left. Header chrome followed the same flag, so the sticky bar lost its solid fill mid-slide.
Outside click, Escape, and Next.js client navigation all hit the same path. Clear value, start exit, dim gone, panel still moving.
Use the demo below. Leave menu value drives dim selected, open Women or Men, then close the flyout. The backdrop drops before the panel finishes sliding. Switch to surfaceOpen and close again: the dim holds until the simulated exit animation completes.
Interactive
Mega menu backdrop vs close animation
Fake header shell and portaled panel. Toggle wiring: menu value drives the dim, or surfaceOpen waits for close complete.
Menu value cleared: dim and header chrome dropped instantly while the panel was still sliding.
Split openMenuKey from surfaceOpen
I stopped treating "which item is open" and "should chrome look open" as one boolean.
openMenuKey stays the Navigation Menu value. It tells Base UI which flyout to show and clears on navigate or close intent. surfaceOpen is separate. It goes true when any menu opens. It only goes false inside onOpenChangeComplete(false), after Base UI says the open/close animation finished.
The nav root gets data-surface-open. Backdrop and header shell read surfaceOpen, not openMenuKey. When value clears on close start, I leave surfaceOpen alone until complete fires.
const [{ openMenuKey, surfaceOpen }, setMenuState] = useState({
openMenuKey: null as string | null,
surfaceOpen: false,
})
<NavigationMenu
data-surface-open={surfaceOpen ? '' : undefined}
value={openMenuKey}
onValueChange={(next) => {
setMenuState((current) => ({
openMenuKey: next,
// Keep dim through exit when value clears at animation start
surfaceOpen: next === null ? current.surfaceOpen : true,
}))
}}
onOpenChangeComplete={(open) => {
if (!open) setMenuState((current) => ({ ...current, surfaceOpen: false }))
}}
>
{/* invisible is not transitioned; opacity handles fade */}
<div
data-open={surfaceOpen ? '' : undefined}
className="... invisible opacity-0 transition-opacity data-open:visible data-open:opacity-100"
/>
</NavigationMenu>Comment the invisible line in real code. The next person will "fix" the flicker by removing it and wonder why focus rings bleed through a transparent layer.
Reuse on any animated overlay chrome
Any controlled surface that animates closed can leave your chrome behind if you key UI off the same value that clears early. Drawers, command palettes, mobile nav sheets: if the library fires "closed" at animation start, split intent from presentation. Intent is which item or route owns focus. Presentation is whether the page should still look dimmed until motion ends.
I still clear openMenuKey immediately on link click so the next page does not inherit an open flyout. Chrome waits. That only clicks once you know Base UI schedules value null at exit start.
The dim is not decoration. It is the signal that the page below the header is inactive. If it disappears before the panel does, shoppers read that as a bug, and they are right.
Happy coding! Sander