Debugging the Animation Effect Stack
Part of CSS Animation Composition & Layering in Core CSS Animation Fundamentals.
The problem
A button’s hover transition stopped working. The CSS for :hover is correct, the Styles pane shows the new transform value matched, and the Computed pane shows a completely different one. Nothing in the stylesheet produces that value. Or: a menu’s close animation plays, but at the end the menu is visible again. Or: a card is frozen at opacity: 0.4 long after every animation on the page appears to have ended.
All three are effect-stack problems. Something animating the property is winning over the value you are looking at, and the Styles pane does not show animations as rules.
Root cause analysis: animations sit above your rules
Animated values do not live in the Styles pane’s world of rules. The cascade has a separate origin for animation declarations, which ranks above every normal author declaration — including :hover, classes and inline styles — and below !important ones. Whatever a running or filling animation says about a property is what the element gets, no matter which ordinary rules match.
Within that origin, effects are combined in composite order, which is also the order getAnimations() returns them: CSS transitions first, then CSS animations in the order their names appear in animation-name, then script animations from element.animate() in creation order. With the default replace composition, the later effect wins.
Transitions have their own origin at the very top of the cascade, but a transition only exists if a property’s computed value actually changes. That is the key to the “dead hover” bug. If an entrance animation fills forwards on transform, the computed transform is the animation’s end value. Hovering changes the rule underneath, but not the computed value, so no transition is ever created — and when the fill is removed later, the value jumps.
Three situations produce most “impossible” values. A CSS animation with fill-mode: forwards or both that has finished keeps applying its end value. A script animation with fill: 'forwards' does the same and survives class changes because it is not tied to any selector. And an animation listed later in animation-name replaces an earlier one on the same property unless composition says otherwise.
Step-by-step resolution
Production code pattern
// Console helper: which effects touch which properties on an element?
function effectStack(el = $0) {
return el.getAnimations().map((a, i) => {
const effect = a.effect;
const timing = effect.getComputedTiming();
const props = new Set(effect.getKeyframes().flatMap((k) =>
Object.keys(k).filter((p) => !['offset', 'easing', 'composite', 'computedOffset'].includes(p))));
return {
order: i,
type: a.constructor.name, // CSSTransition, CSSAnimation, Animation
name: a.animationName ?? a.transitionProperty ?? a.id ?? '(script)',
playState: a.playState,
fill: timing.fill,
progress: timing.progress,
composite: effect.composite,
properties: [...props].join(', '),
};
});
}
console.table(effectStack());
// Clearing a finished script effect that is still filling:
for (const a of $0.getAnimations()) {
if (a.playState === 'finished' && a.effect.getComputedTiming().fill !== 'none') {
a.commitStyles?.(); // keep its visual result as inline style, if wanted
a.cancel(); // then remove it from the stack
}
}
/* The usual fix: an entrance that fills forwards buries the hover transition. */
.card {
animation: card-in 400ms ease-out; /* no forwards fill: end state is the base style */
transition: transform 160ms ease-out;
}
.card:hover { transform: translateY(-4px); }
@keyframes card-in { from { opacity: 0; transform: translateY(12px); } }
@media (prefers-reduced-motion: reduce) {
.card { animation: none; transition: none; }
}
Rendering Impact: none for inspection. A filling animation that has finished still keeps the element’s animated property under animation control, which can keep a compositor layer alive long after motion ends — another reason to avoid
forwardsfills whose end value equals the base style.
getAnimations() returns effects in their composite order, so the array index already reflects the stack from bottom to top. document.getAnimations() lists every effect on the page, which is useful when the conflicting effect is on an ancestor animating an inherited custom property.
Verification checklist
Constraints and trade-offs
- Removing a forwards fill means the end state must exist in regular CSS.
commitStyles()preserves visuals but adds inline styles that override the stylesheet.getAnimations()excludes animations on pseudo-elements unless called with{ subtree: true }on the parent.- Idle effects that have not started yet can also hold values with
backwardsfill during their delay. - A transition that starts while an animation also targets the property can briefly override it, because transitions rank highest in the cascade.
Frequently asked questions
Why does my hover transition not work on an element with an entrance animation?
If the entrance animation fills forwards on the same property, the animation keeps the computed value fixed, so the hover rule never changes it and no transition starts. Remove the fill and put the end value in the base style.
Do script animations override CSS animations?
On the same property, yes. Animations created with element.animate() come after CSS animations in composite order, so with replace composition they win, and they persist regardless of selector changes while they are filling.
How do I see animations that the Styles pane does not show?
Use element.getAnimations() in the Console or the Animations drawer. Animated values are not rules, so the Styles pane only shows the cascade underneath them.
Related
- CSS Animation Composition & Layering — the parent topic
- Debugging Transitions That Never Fire — when the transition itself is missing
- commitStyles() & Persisting End State — replacing fills with real styles