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)

  1. Exit animation cut off because display and overlay are missing from the transition list (the most common mistake). An element toggling display:none (dialog, popover, top-layer) needs display in the transition list with transition-behavior: allow-discrete, or the element flips to none at once and the exit is never seen. For top-layer elements also transition overlay (with allow-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.
  2. allow-discrete placement — prefer a separate transition-behavior line, and put it AFTER the shorthand. Two independent traps push the same fix. (a) A standalone transition-behavior: allow-discrete; written before the transition shorthand is reset by the shorthand and ignored — it must come after. (b) Baking allow-discrete (or overlay) inside the transition shorthand 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 separate transition-behavior: allow-discrete; line after the shorthand degrades to just that one line being dropped, leaving the base transition intact.
  3. @starting-style must target the open-state selector, and sit after it. Transitions do not fire on first style update or on the display:none->visible flip, so the entry animation needs a @starting-style block 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.
  4. ::backdrop needs its own selector and its own @starting-style. Give dialog::backdrop / [popover]::backdrop separate transition declarations and, if it animates in, its own @starting-style. That selector may be top-level or use native nesting such as dialog { &::backdrop { ... } }; the defect is inheriting or omitting the backdrop's state, not nesting itself.
  5. Do not gate cleanup/focus/unmount on transitionend alone — it does not always fire. When the element is removed from the DOM, set to display:none, or the transition is cancelled (re-interrupted, ESC-closed mid-transition), transitionend is not generated. The cancel path fires transitioncancel instead (a standard, cross-browser event, Baseline since 2020 — not transitionend), and some interrupt/removal paths can still emit no usable transition event at all. Real bug class: React Aria's focus restoration runs through runAfterTransition, which must special-case transitioncancel and node removal precisely because transitionend is 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.
  6. 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 that finished rejects (AbortError) when an animation is cancelled: run cleanup via .then(done, done) (or await inside try/finally with 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 to transitionrun + transitionend + transitioncancel together and add a duration-based timeout fallback so a missing event cannot wedge the lifecycle.
  7. 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:

Installs
1
GitHub Stars
1
First Seen
3 days ago
css-transition-animation-contracts — voidmatcha/frontend-niche-skills