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 filed it as me wiring 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.
Mega menu backdrop vs close animation
Menu value cleared: dim and header chrome dropped instantly while the panel was still sliding.
Toggle the demo. With menu value driving the dim, close Women or Men and watch the backdrop drop before the panel finishes. Flip to surfaceOpen and the dim holds until the simulated close completes.
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 is the whole trick, and it 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