04
Overlay lifecycle
mount → closing → unmount. The node stays mounted through its 100ms exit and unmounts on a matching animationend (with a timeout fallback). Reduced motion short-circuits the closing phase entirely.
useOverlayPhase()anim-menu-in/outanim-dialog-in/outanim-scrim-in/outAnchored menus HINGE from their anchored edge (scaleY from the top); centered dialogs SCALE from center. Both enter on the pop spring — both leave in 100ms, flat, no spring. And the scrim always enters one tier faster than its panel.
Anchored menus — single-open bus
open one, then the other: peers close themselves. Esc / outside-click dismisses.
Centered dialog
scrim: 160ms base · panel: 380ms pop spring · exits together in 100ms
The two-phase contract
closing keeps the DOM alive for its exit
const o = useOverlayPhase(); // closed → open → closing → closed
{o.isMounted && (
<div ref={o.panelRef}
className={o.isClosing ? "anim-menu-out" : "anim-menu-in"} />
)}
// unmounts on animationend matching /-out$/ — 240ms timeout as a fallback;
// prefers-reduced-motion skips "closing" and unmounts immediately