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.
Step-by-step diagnosis
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;displayis 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.
Verification checklist
Constraints and trade-offs
@starting-styleonly supplies a before-state for elements becoming rendered; it does nothing for an element that was already on screen.allow-discretedefersdisplayuntil 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.
Related
- CSS Transitions vs Animations — the parent guide on which mechanism to choose in the first place
- CSS @starting-style & Entry Effects — supplying the missing before-state for inserted elements
- Registering Custom Properties with @property — the fix for a transition that snaps at the midpoint