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.

What decides an animated property's valueAnimation declarations beat every normal rule; transitions only appear when the computed value changes.What decides an animated property's valueTransitions originOnly if the computed value changed!important declarationsOverride animationsScript animations (element.animate)Top of composite order; fills persistCSS animationsanimation-name order; fills persistNormal rules, including :hoverWhat the Styles pane shows
Animation declarations beat every normal rule; transitions only appear when the computed value changes.

Step-by-step resolution

Finding the effect that winsStart from getAnimations(); it shows what the Styles pane cannot.Finding the effect that wins1Select the element in the Elements panel so it is available as $0 in the Console.Quick access without selectors2Run the logging snippet below to list every effect with its properties.Type, name, state, fill and composite3Look for finished effects with a forwards or both fill on the property in question.The most common hidden winner4Check whether a transition is listed at all while an animation is also present.No transition means the computed value never changed5Scrub the Animations drawer and match keyframe values to the computed value.Confirms which effect is responsible6Remove the stale fill, cancel the script effect, or split properties.
Start from getAnimations(); it shows what the Styles pane cannot.

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 forwards fills 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.

Symptom to likely cause in the stackMost problems are fills that outlive the animation.Symptom to likely cause in the stackLikely causeFixHover transitiondoes nothingAnimation filling on same propertyRemove forwards fillElement reappearsafter closeScript animation fill cancelledcommitStyles, then cancelValue never changeswith classScript fill not tied to selectorsCancel on state changeSecond animationignoredEarlier name wins by compositionReorder or use add
Most problems are fills that outlive the animation.

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 backwards fill 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.