Debugging CSS Transitions That Never Fire

Part of CSS Transitions vs Animations in Core CSS Animation Fundamentals.

The problem

The declaration is right there in the stylesheet, the class is definitely being added, the property definitely changes — and the change happens instantly, with no interpolation. Transitions fail silently: there is no console warning, no DevTools badge, and the Animations panel simply shows nothing, because from the browser’s point of view no animation was ever created.

Almost every instance has one of five causes, and they can be separated in about a minute if you check them in the right order.

Root cause: a transition needs two resolved values and two style passes

A transition is created when the computed value of a transitionable property changes between two style resolutions. Every failure is a violation of one of those clauses.

No previous value. An element that has just been inserted, or that has just become rendered after display: none, has no previous computed style. Its first style is also its final style, so nothing changed and nothing transitions. This is exactly the gap @starting-style exists to fill.

No resolvable value. auto, none, min-content and friends are not numbers. height: 0 to height: auto has no interpolable pair, so the property snaps. The same applies to a base rule that never declared the property at all: if the resting state has no translate, there is no start value for the hover state to travel from.

A discrete property. display, visibility, content-visibility and overlay change discretely. Without transition-behavior: allow-discrete they flip at once, and — worse — a display: none that applies immediately takes the element out of the render tree before any other transition on it can run.

Both values in one task. Style resolves once per frame. Setting a property and then changing it inside the same task produces one computed style, and one computed style is no change at all.

The declaration lost the cascade. A transition in a base rule overridden by a later shorthand, a transition: none in a reduced-motion block that is matching when you did not expect it, or a transition-property list that does not include the property being changed.

The five gates a transition has to passFailing any one of them produces the same symptom: an instant change with no animation.The five gates a transition has to passPreviouscomputed valueexistsBoth valuesresolvableProperty istransitionableChange spanstwo stylepassesDeclarationsurvives thecascadeTransitioncreated
Failing any one of them produces the same symptom: an instant change with no animation.

Step-by-step diagnosis

Isolating the cause in five checksRun them in this order; each one is cheaper than the next.Isolating the cause in five checks1Open the Styles pane and read the computed transition-property list on theelement.If your property is not listed, the cascade is the problem2Check the base rule declares the property with a concrete value.A missing start value is the most common cause3Look for auto, none or an intrinsic keyword at either end.Neither end can be a non-numeric keyword4If display or visibility is involved, add transition-behavior:allow-discrete.The discrete flip now defers until the other transitions finish5If the element was just inserted, add an @starting-style block for thefrom-values.The missing previous style is supplied explicitly
Run them in this order; each one is cheaper than the next.

Production code pattern

/* Intent: a disclosure panel that animates open and closed, covering all five
   failure modes explicitly rather than by accident. */
.panel {
  /* 1. Concrete start values, so both ends are resolvable. */
  opacity: 0;
  translate: 0 -6px;
  display: none;

  /* 2. The transition lives on the element, so it applies in both directions.
     3. allow-discrete lets display defer until the fade finishes. */
  transition:
    opacity 200ms cubic-bezier(0, 0, 0.2, 1),
    translate 200ms cubic-bezier(0, 0, 0.2, 1),
    display 200ms allow-discrete;
}

.panel[data-open='true'] {
  opacity: 1;
  translate: 0 0;
  display: block;
}

/* 4. The element becomes rendered and styled in the same pass, so it needs an
   explicit before-state or the entry has nothing to travel from. */
@starting-style {
  .panel[data-open='true'] {
    opacity: 0;
    translate: 0 -6px;
  }
}

/* 5. One gate, declared last so it cannot be overridden by a component rule. */
@media (prefers-reduced-motion: reduce) {
  .panel { transition: display 1ms allow-discrete, opacity 1ms; }
}

Rendering Impact: opacity + translate — composite only; display is discrete and deferred, so it costs one style pass at each end rather than anything per frame.

The ordering in that block is the lesson. Concrete resting values first, the transition on the element rather than on the open state, allow-discrete for the discrete property, @starting-style for the insertion case, and the accessibility gate last so nothing overrides it.

Symptom to causeMatch the exact symptom rather than the general one — 'no animation' has five different fixes.Symptom to causeMost likely causeFixNothing animates, ineither directionProperty not in the computedlistCheck the cascade in the StylespaneOpens instantly, closescorrectlyNo previous computed styleAdd an @starting-style blockOpens correctly, closesinstantlydisplay: none applies at onceAdd transition-behavior:allow-discreteSnaps at the midpointUnregistered custom propertyRegister it with @propertyWorks once, never againSame-task style changeSplit the writes across twoframesFires unexpectedly onloadClass applied during hydrationSuppress transitions untilafter first paint
Match the exact symptom rather than the general one — 'no animation' has five different fixes.

Verification checklist

Constraints and trade-offs

  • @starting-style only supplies a before-state for elements becoming rendered; it does nothing for an element that was already on screen.
  • allow-discrete defers display until other transitions finish, which means a long transition keeps a hidden element in the tree for that duration.
  • Suppressing transitions during hydration with a root class must be removed after the first paint, or nothing on the page will ever animate.
  • Registering a custom property changes its interpolation but not the cascade rules that might still be dropping your declaration.
  • A transition that fails silently in one browser may work in another; check the computed value in each rather than assuming the CSS is at fault.

Frequently asked questions

Why does a transition from height: auto do nothing?

Because auto is not a length, so there is no start value to interpolate from. Either transition to and from explicit lengths, use the modern interpolate-size and calc-size support where available, or animate a compositor-safe substitute such as scaleY on a wrapper.

Why does my transition fire on page load?

The element had one computed style during the first style pass and a different one immediately after, usually because a class is applied by script during hydration. Either apply the class before the first paint, or suppress transitions until after load with a temporary no-transitions class on the root.

Do transitions work on elements that were just inserted?

Not by themselves: a newly inserted element has no previous computed style, so the first style it gets is also its final one. @starting-style supplies the missing before-state, which is what makes entry transitions possible.


The two failures that are not transitions at all

Two symptoms in this family are commonly diagnosed as broken transitions and are actually something else.

The animation runs but nothing appears to move. The transition is firing correctly on a property that has no visible effect in this context — translate on an element with position: static inside a flex container that is already centring it, or opacity on an element whose parent is already at zero opacity. The Animations panel shows an entry, the computed value changes, and the pixels do not. Check the parent chain before the declaration.

The element moves, but not smoothly. The transition is fine; the property is layout-tier, and each frame is running layout for the subtree. On a small page it merely looks slightly stiff; under load it drops half its frames. This is the failure that the Performance panel diagnoses instantly and the Animations panel does not show at all, because the animation is running exactly as declared.

The distinction matters because the fixes are unrelated. A transition that never fires is a cascade or a resolvability problem, addressed in the checks above. A transition that fires and does the wrong thing is a property-choice problem, addressed by moving to the compositor-safe property set. Diagnosing the second as the first leads to an afternoon of adding @starting-style blocks that change nothing.