Enter/exit motion for <dialog>, popover, and anything toggling display looks fine in the happy path and breaks at the edges: the exit animation is skipped, the first open does not animate, or JS gated on transitionend never runs. This is a review lens for two failure families — discrete-property enter/exit on the top layer, and interrupted transitions whose completion event never arrives. It is not a how-to-animate tutorial (Chrome/MDN own that); it owns the gotcha/review layer.
Checklist (lead with the trap)
- Exit animation cut off because
displayandoverlayare missing from the transition list (the most common mistake). An element togglingdisplay:none(dialog, popover, top-layer) needsdisplayin thetransitionlist withtransition-behavior: allow-discrete, or the element flips tononeat once and the exit is never seen. For top-layer elements also transitionoverlay(withallow-discrete) so removal from the top layer is deferred until the animation ends — without it the element jumps out of the top layer (behind siblings) before it can animate out. Entry-only setups are the tell: they animate in but vanish on close. allow-discreteplacement — prefer a separatetransition-behaviorline, and put it AFTER the shorthand. Two independent traps push the same fix. (a) A standalonetransition-behavior: allow-discrete;written before thetransitionshorthand is reset by the shorthand and ignored — it must come after. (b) Bakingallow-discrete(oroverlay) inside thetransitionshorthand value means a browser that does not know the keyword invalidates the entire declaration (CSS drops the whole value when any part is invalid), killing even the opacity/transform transition. A separatetransition-behavior: allow-discrete;line after the shorthand degrades to just that one line being dropped, leaving the base transition intact.@starting-stylemust target the open-state selector, and sit after it. Transitions do not fire on first style update or on thedisplay:none->visible flip, so the entry animation needs a@starting-styleblock defining the from-state. It must select the open state (:popover-open, or[open]for<dialog>) and be written after that rule (equal specificity, source order wins). Targeting the base/closed selector is a no-op and the entry silently does not animate.::backdropneeds its own selector and its own@starting-style. Givedialog::backdrop/[popover]::backdropseparate transition declarations and, if it animates in, its own@starting-style. That selector may be top-level or use native nesting such asdialog { &::backdrop { ... } }; the defect is inheriting or omitting the backdrop's state, not nesting itself.- Do not gate cleanup/focus/unmount on
transitionendalone — it does not always fire. When the element is removed from the DOM, set todisplay:none, or the transition is cancelled (re-interrupted, ESC-closed mid-transition),transitionendis not generated. The cancel path firestransitioncancelinstead (a standard, cross-browser event, Baseline since 2020 — nottransitionend), and some interrupt/removal paths can still emit no usable transition event at all. Real bug class: React Aria's focus restoration runs throughrunAfterTransition, which must special-casetransitioncanceland node removal precisely becausetransitionendis unreliable (see react-spectrum issue #7326, detached nodes retained when a multi-value transition is cancelled). Symptom: focus never restored, overlay never unmounts, body scroll stays locked. - Gate on the animation finishing, not on one event. Prefer
Promise.all(el.getAnimations({subtree:true}).map(a => a.finished))— one code path that settles on complete or cancel. Mind thatfinishedrejects (AbortError) when an animation is cancelled: run cleanup via.then(done, done)(orawaitinsidetry/finallywith the rejection caught) — bare.then(done)skips cleanup on cancel, and a bare.finally(done)runs cleanup but still leaves the rejection unhandled. If you must use events, listen totransitionrun+transitionend+transitioncanceltogether and add a duration-based timeout fallback so a missing event cannot wedge the lifecycle. - Honor
prefers-reduced-motion. Wrap decorative motion in@media (prefers-reduced-motion: reduce)and tone it down (e.g. to a fade) rather than blanket* { animation: none !important; }— some flows depend on the completion event, and JS-driven (Web Animations) motion is not covered by the CSS hack. Motion that is essential to conveyed meaning may stay.
Quick probes
Use these as leads, then read the actual open/close CSS and the JS that waits on it:
rg -n 'allow-discrete|transition-behavior|@starting-style|\boverlay\b' src/ app/ packages/ 2>/dev/null
rg -n 'transitionend|transitioncancel|transitionrun|animationend|onTransitionEnd' src/ app/ packages/ 2>/dev/null
rg -n 'getAnimations|\.finished|::backdrop|:popover-open|\[open\]|runAfterTransition' src/ app/ packages/ 2>/dev/null
rg -n 'prefers-reduced-motion|matchMedia\(.*reduced-motion' src/ app/ packages/ 2>/dev/null
For a candidate exit-animation bug, confirm the transition list actually contains display and (for top-layer) overlay; a match on transition: alone is not evidence.
Boundary with sibling skills
- Use css-transition-animation-contracts for the transition/animation lifecycle: discrete-property enter/exit (
display/overlay/allow-discrete/@starting-style/::backdrop) and completion-event reliability (transitionend/transitioncancel/getAnimations().finished). - Use overlay-focus-scroll-contracts for the overlay contract itself — focus trap/restore,
inert/aria-hidden, scroll lock, dismissal, nested stacks. This skill adds the transition-lifecycle layer beneath it: the exact reason a lock or trap outlives the visual close is usually a completion event that never fired. - Use view-transitions-contracts for the View Transitions API (
document.startViewTransition,::view-transition-*) — a different mechanism from CSS transitions, not covered here. - Use a11y-contract-testing for reduced-motion behavior verification and for role/name/state assertions on the animated element.
- Prior art: GoogleChrome/web.dev modern-web-guidance covers how to build these entry/exit animations; this skill owns the review/gotcha lens — the failure modes and what to flag in a diff.
PR-worthiness gate
Count a finding only when all hold:
- A real user path triggers the open/close or the gated cleanup.
- The defect is user-visible or state-corrupting: exit skipped, entry never animates, backdrop does not fade, transition broken in a supported browser, or focus/unmount/scroll-unlock wedged.
- Evidence is the actual CSS transition list /
@starting-styleselector / event-gated code, not just a grep hit. - The fix is narrow: add
display+overlayto one transition list, move/split onetransition-behaviorline, re-target one@starting-style, give::backdropits own selector/declarations, or swap onetransitionendgate forgetAnimations().finishedplus a timeout.
Reject weak findings:
- A pure CSS
@keyframesanimation (not a transition to/fromdisplay:none) does not need@starting-styleorallow-discrete. transitionshorthand withoutallow-discreteis fine for non-discrete properties; the exit-animation trap only applies whendisplay/overlayare being toggled.- Radix, Headless UI, and similar libraries may already gate unmount on animation completion — inspect their config before filing.
- A
setTimeout(duration)cleanup is a valid fallback, not automatically a bug.
Output shape
Return compact findings:
- Contract: discrete-property enter/exit,
@starting-styletarget,::backdroprule, or completion-event gate. - Evidence: file/line, the transition list or event-gated code path.
- Risk: exit skipped, entry not animated, backdrop static, older-browser breakage, or stuck focus/unmount/scroll-lock.
- Fix: the one-line/one-rule change above.
- Verification: a browser check of open->close (and an interrupt: reopen or ESC mid-transition), or a test asserting cleanup runs on cancel.
Sources
- transition-behavior (allow-discrete, discrete display/overlay) - MDN
- @starting-style at-rule - MDN
- overlay property (defers top-layer removal) - MDN
- Using CSS transitions (display + allow-discrete + @starting-style) - MDN
- Using CSS nesting (
&with appended pseudo-element selectors) - MDN ::backdroppseudo-element - MDN- Four new CSS features for smooth entry and exit animations - Chrome for Developers
- Now in Baseline: animating entry effects - web.dev
- Element: transitionend event (not fired when transition removed) - MDN
- Element: transitioncancel event - MDN
- Element: getAnimations() (.finished promise cleanup) - MDN
- prefers-reduced-motion media feature - MDN
- Animation.finished - MDN (the promise rejects with AbortError when the animation is cancelled)
- react-spectrum runAfterTransition.ts (transitioncancel + node-removal handling; commit-pinned — the file moved in the #9774 package consolidation)
- react-spectrum #7326: detached nodes retained when a multi-value transition is cancelled