css-transition-animation-contracts
Installation
SKILL.md
CSS transition/animation contracts
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: