Speed1×
Slow motion multiplier
Reduced motion
Show
The exit gap
conflicts with the published pageTune the presence budget
enter
exit
overlay
Tooltip
useLayer · highest-frequency surface in the systemToday — animates in, vanishes out
Tooltip
Proposed — the exit retraces the entry
Tooltip
HoverCard
useLayer · the most visible instance of the gapToday — animates in, vanishes out
HoverCard
Proposed — the exit retraces the entry
HoverCard
Popover
useLayerToday — animates in, vanishes out
Popover
Proposed — the exit retraces the entry
Popover
DropdownMenu
useLayer · submenus tooToday — animates in, vanishes out
DropdownMenu
Proposed — the exit retraces the entry
DropdownMenu
Dialog, AlertDialog, CommandPalette
focus races the exitToday — no exit, scrim hard-cuts, focus returns immediately
Delete workspace?This cannot be undone.
Proposed — panel and scrim both exit, focus waits
Delete workspace?This cannot be undone.
In the left pane the focus ring lights the instant the dialog closes, while the dialog is still on screen. With a real exit animation that means focus moves — and the page may scroll — mid-animation. The brief lists this as a risk; here it is a thing you can watch, and it has to be settled before the Dialog work starts rather than after.
The technique, running natively in this browser
A native popover transitioning
display and overlay with allow-discrete, entering from @starting-style. This is the proposed implementation, not a mock-up of it.What one change buys
Every component below is a
useLayer consumer, so all of them inherit a dismissal animation from a single definition. That is the leverage — and it is also the risk, because it is one change to the code path that owns dismissal.TooltipHoverCardPopoverDropdownMenuContextMenuSelectorComplexSelectorPowerSearchTypeaheadMoreMenuBreadcrumbs overflow
layerAnimations — one definition, eleven consumers — css.layer {opacity: 0;transform: scale(0.96) translateY(-4px);transform-origin: var(--layer-origin);transition:opacity var(--duration-exit) var(--ease-exit),transform var(--duration-exit) var(--ease-exit),display var(--duration-exit) allow-discrete,overlay var(--duration-exit) allow-discrete;}.layer:popover-open {opacity: 1;transform: scale(1) translateY(0);transition:opacity var(--duration-enter) var(--ease-entry),transform var(--duration-enter) var(--ease-entry),display var(--duration-enter) allow-discrete,overlay var(--duration-enter) allow-discrete;}@starting-style {.layer:popover-open { opacity: 0; transform: scale(0.96) translateY(-4px); }}
useLayer — await the exit instead of hiding synchronously — tsx- setOpen(false); // element gone this frame+ setClosing(true); // stops accepting input immediately+ await Promise.all(+ el.getAnimations().map(a => a.finished),+ );+ setOpen(false); // focus return happens here
What this work has to be careful about
Dismissal is load-bearingLayer dismissal has its own invariants test for good reason. Keeping an element alive through its exit doubles the state matrix for focus return, outside-press and escape handling — and every one of those has to be tested against an element that is visible but already closing.
The published page already warns about this“Animations that block interaction are worse. If a user has to wait for a panel to finish sliding before they can click something inside it, the animation has become an obstacle.” The answer has to be that the surface stops accepting input the moment dismissal starts, not when the animation ends. Write that into the primitive’s contract rather than leaving it to each consumer.
Motion is already fighting the automated gatesTwo fixes landed this month to stop animation interfering with the accessibility audit and the visual-regression capture. More motion means more of this, and there is no documented policy for it. The lab’s reduced-motion switch doubles as the freeze mode a capture needs.