CSS Animation Not Playing — How to Find and Fix Every Silent Cause
Quick answer
💡When a CSS animation does not play, the most likely causes are a zero animation-duration (the default), a keyframe name that does not match exactly, display: none on the element, animation-play-state: paused set by JavaScript, or overflow: hidden on a parent clipping a transform that moves the element outside its bounds. Check these five causes first before looking elsewhere.
Error symptoms
- ✕
Element is present in the DOM but shows no motion at all - ✕
Animation plays the first time then never repeats - ✕
Animation worked yesterday but stopped after a JavaScript change - ✕
Animation plays on desktop but is invisible on mobile - ✕
DevTools Animations panel shows the animation but the element is frozen - ✕
Animation plays in an isolated CodePen but not in the actual application
Common causes
- •animation-duration is 0s (the browser default) so the animation completes instantly
- •@keyframes name does not match animation-name (comparison is case-sensitive)
- •Element has display: none applied by a parent component or media query
- •JavaScript set element.style.animationPlayState to paused after the component mounted
- •overflow: hidden on a parent clips transformed children that leave the container bounds
- •prefers-reduced-motion: reduce user preference disables animations in the browser
When it happens
- •After a refactor moves a component into a container that adds overflow: hidden
- •When JavaScript conditionally sets animationPlayState based on a flag that starts as paused
- •On pages where a CSS reset or third-party stylesheet sets animation: none globally
- •When the animation-name is generated by a CSS preprocessor and the output has a different casing
- •In React or Vue when the component is conditionally rendered with display: none via v-show
Examples and fixes
An element hidden with display:none cannot transition to any animated state because it has no computed layout.
display:none prevents animation from starting
❌ Wrong
<div class="overlay" hidden>
<div class="modal" style="animation: slide-in 300ms ease both">
Content
</div>
</div>
.modal {
animation: slide-in 300ms ease both;
}
@keyframes slide-in {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}✅ Fixed
<div class="overlay">
<div class="modal">
Content
</div>
</div>
.overlay {
visibility: hidden;
opacity: 0;
transition: opacity 200ms ease;
}
.overlay.is-open {
visibility: visible;
opacity: 1;
}
.modal {
animation: slide-in 300ms ease both;
}
@keyframes slide-in {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}The HTML hidden attribute sets display: none, which removes the element from the layout tree entirely. When JavaScript removes the hidden attribute, the element appears in a single paint without any animation because the keyframe already completed in zero layout time. The fix replaces display toggling with visibility and opacity. The element remains in the layout tree with visibility: hidden, so when the is-open class is added the animation starts from a real computed position and plays correctly. Visibility: hidden elements are invisible but still occupy space, which is the key difference from display: none.
A component that pauses animations on tab blur leaves them paused when the tab regains focus.
JavaScript sets animationPlayState to paused
❌ Wrong
<div class="spinner"></div>
// JavaScript — runs on every visibilitychange
document.addEventListener('visibilitychange', () => {
const spinner = document.querySelector('.spinner');
spinner.style.animationPlayState =
document.hidden ? 'paused' : 'paused'; // typo: always paused
});
.spinner {
animation: spin 1s linear infinite;
will-change: transform;
}
@keyframes spin {
to { transform: rotate(360deg); }
}✅ Fixed
<div class="spinner"></div>
// JavaScript
document.addEventListener('visibilitychange', () => {
const spinner = document.querySelector('.spinner');
spinner.style.animationPlayState =
document.hidden ? 'paused' : 'running';
});
.spinner {
animation: spin 1s linear infinite;
will-change: transform;
}
@keyframes spin {
to { transform: rotate(360deg); }
}The broken version contains a typo — it sets animationPlayState to paused in both branches of the ternary, so the spinner freezes the first time the user switches tabs and never recovers. The fix sets it to running when the tab becomes visible again. This pattern is correct: browsers already pause animations automatically on hidden tabs, but explicitly managing the state via JavaScript is useful when you need to control animation playback independent of tab visibility, such as pausing during a data fetch.
A side-drawer that slides in from outside the viewport is clipped by an ancestor with overflow:hidden.
overflow:hidden clips a translate animation
❌ Wrong
<div class="page-wrapper">
<nav class="drawer">Menu</nav>
</div>
.page-wrapper {
overflow: hidden; /* added to prevent horizontal scroll */
}
.drawer {
transform: translateX(-100%);
animation: drawer-in 350ms ease forwards;
}
@keyframes drawer-in {
to { transform: translateX(0); }
}✅ Fixed
<div class="page-wrapper">
<nav class="drawer">Menu</nav>
</div>
.page-wrapper {
overflow-x: clip; /* clips horizontal overflow without creating a new scroll context */
}
.drawer {
transform: translateX(-100%);
animation: drawer-in 350ms ease forwards;
}
@keyframes drawer-in {
to { transform: translateX(0); }
}overflow: hidden creates a new stacking context and clips any content that extends beyond the element's border box, including absolutely positioned children and transformed elements that move outside the bounds during animation. The drawer starts at translateX(-100%), which is outside the wrapper, so it is invisible throughout the animation. Changing overflow: hidden to overflow-x: clip prevents the horizontal scrollbar without creating a scroll container, which means the drawer can animate through the off-screen position and into view. Alternatively, applying overflow: hidden only after the animation completes via animationend also works.
Why CSS animations fail silently
Unlike JavaScript errors, a broken CSS animation produces no console warning, no error in the Elements panel, and no visual indicator that anything went wrong. The browser simply renders the element in its base state as if no animation property existed. This silent failure mode makes debugging slower than most other CSS problems because there is no starting signal — you have to systematically rule out each possible cause.
The most common root cause is a duration of zero seconds. The animation property defaults to 0s for duration, which means if you write animation: spin linear infinite and forget to specify the duration, the animation completes instantaneously and you see nothing. The spec defines this as valid behavior: a zero-duration animation plays and fills according to its fill-mode, but because no time passes, only the initial or final keyframe is ever applied.
The second most common cause is a name mismatch. The animation-name value and the @keyframes identifier must be identical, including casing. @keyframes fadeIn and animation-name: fadein are different names. CSS preprocessors that interpolate animation names from variables can produce output that looks right in the source but has different casing in the compiled CSS. Always inspect the compiled output when using Sass or PostCSS plugins.
A third category of silent failures involves CSS properties that prevent the animation engine from reaching the element at all: display: none, visibility with certain combinations of parent opacity, or the element not yet being in the DOM when the animation starts. Because CSS animations begin at the moment the animation property is computed, adding display: none on the same tick that applies the animation class means the element goes from non-existent to non-existent with a zero-frame animation in between.
Diagnosing animation failures in DevTools
Open Chrome DevTools and navigate to More tools then Animations (or press Ctrl+Shift+P and search Animations). Click the red record button and then trigger the animation on your page. If the animation appears in the panel but the element is not moving, the problem is either display: none, will-change interference, or an inline style overriding play-state. If the animation does not appear in the panel at all, the animation property is not being applied — check the Elements panel Computed tab for animation and confirm the value is not none.
In the Elements panel, select the animated element and look at the Computed styles tab. Filter by animation. You will see the computed values for animation-name, animation-duration, animation-play-state, and every other sub-property. If animation-duration shows 0s, you forgot to set it. If animation-play-state shows paused, something is overriding the default running state. If animation-name shows none, the class that applies the animation is not reaching the element.
For the overflow clipping problem, use the Layers panel (More tools → Layers) to inspect the stacking context. Elements inside an overflow: hidden container that are being clipped will not extend beyond the highlighted boundary. You can also use the Element inspector to temporarily remove overflow: hidden from the parent and see if the animation appears — if it does, overflow is the cause.
When the animation plays but looks wrong, slow it down. In the Animations panel, set playback speed to 10%. This stretches a 300ms animation into 3 seconds and makes it easy to see which keyframe is wrong or missing. You can also click on any animation track in the panel and drag it to scrub to a specific point in the timeline.
Fixing animation that will not start
For the zero-duration problem, always set animation-duration explicitly. Do not rely on the animation shorthand order being memorable — write animation-duration: 300ms; as a separate declaration if the shorthand is confusing. A linting rule (such as the stylelint no-invalid-position-at-import-rule or a custom plugin) can enforce that animation declarations include a non-zero duration.
For the display: none conflict, replace visibility toggling strategy. If you need to hide an element completely until an animation runs, use opacity: 0 with pointer-events: none instead of display: none. The element remains in the layout tree with computed geometry, so when you add the animation class it starts from a real position. When the animation completes, use the animationend event to decide whether to remove the element from the DOM.
For the animationPlayState override, the fix depends on where the override comes from. If JavaScript is setting it via inline style, check every event listener and state management store that might set animationPlayState. Add a console.log or a MutationObserver on the element's style attribute to catch the moment it changes. If a CSS rule is setting it, use the Specificity panel in DevTools to find which rule wins and either increase the specificity of your rule or add !important to the animation-play-state: running declaration as a temporary diagnostic.
For the prefers-reduced-motion case, the browser itself may be honoring a user's OS accessibility setting that disables animations. Check System Preferences on macOS (Accessibility → Display → Reduce Motion) or the equivalent on Windows. To test this without changing OS settings, open DevTools → Rendering tab → Emulate CSS media feature prefers-reduced-motion → reduce. If enabling this makes the animation disappear, your stylesheet is correctly honoring the preference but you may need to provide a static alternative state.
Edge cases: tab visibility, z-index, and React
Browsers pause all CSS animations when a tab becomes hidden via the Page Visibility API. This is correct behavior designed to save battery, but it can confuse debugging sessions where you switch to another tab to write notes and then switch back to find the animation frozen. Most browsers resume animations automatically when the tab regains focus. If they do not, the page may have JavaScript that is explicitly setting animationPlayState to paused on the visibilitychange event without setting it back to running.
Z-index stacking can make an animation appear to be broken when the element is actually playing but hidden behind another element. Open the Layers panel and look for elements with a high stacking order covering the animated element. A transform animation that moves the element behind a sibling with a higher z-index will appear invisible even though the animation is running correctly. Fix this by adjusting z-index on the animated element or its parent stacking context.
In React, animations attached to a component that uses conditional rendering (if someFlag and component) are often invisible because React mounts the element and computes styles in the same paint. The animation starts at computed time, but if the element was not previously in the DOM, it begins from an undefined state that the browser may optimize away. Use a CSS class toggle instead of conditional rendering, keeping the element in the DOM but invisible with opacity: 0, and add the animation class as a second step.
In Safari on iOS, certain animations on elements inside a fixed-position parent fail due to the way iOS handles fixed positioning in scroll containers. The animation may play but appear to jump or teleport rather than interpolate. Apply transform: translateZ(0) to the fixed parent as a workaround, which creates a new stacking context and resolves the compositing conflict. This is a known iOS Safari bug that has persisted across multiple versions.
Mistakes that silently stop animations
Setting animation: none in a CSS reset and not overriding it in the component stylesheet is a frequent problem in large codebases. Some CSS reset libraries, including Eric Meyer's reset and certain older Tailwind base configurations, include animation: none in their base styles. If your animation property has lower specificity than the reset, it will be overridden. Check the Computed styles and look for the source of the animation declaration — it may be coming from a stylesheet loaded before yours.
Writing the animation shorthand with the name before the duration is valid but easy to misread. The spec requires the name to be parseable as a string that is not a time value, so spin 300ms is unambiguous. However, spin 300ms ease-in-out 500ms applies duration 300ms, timing ease-in-out, and delay 500ms. If you intended delay 300ms and duration 500ms, the order is wrong. Naming your keyframes with descriptive strings rather than single words (slide-in-left rather than slide) helps prevent confusion with reserved timing-function keywords.
Failing to add the animation to the element before it enters the viewport means users who scroll quickly may never see it. CSS animations that are triggered by JavaScript scroll listeners need to account for the case where the element is already in the viewport on page load. Check whether the IntersectionObserver fires synchronously for elements already visible, or add an explicit check at the intersection setup time.
Nesting @keyframes inside a media query is not supported in CSS. The rule must be at the top level or inside a @layer, @supports, or @scope block. Some developers attempt to write responsive animations by putting different @keyframes in different breakpoints, but the browser ignores inner @keyframes. Instead, change the animation property values inside the media query and reference the same @keyframes name.
Best practices for reliable CSS animations
Declare animation-duration explicitly in every rule that uses animation. Even if you use the animation shorthand, writing a separate animation-duration: 400ms alongside it makes the duration visible to code reviewers and reduces the risk of accidentally inheriting a zero value from a parent rule. Many CSS linters can be configured to warn when an animation property is present without a duration.
Always test animations in Chrome DevTools with the CPU throttle set to 4x slowdown and the network set to slow 3G. This simulates a mid-range mobile device under load and surfaces frame drops and paint costs that are invisible on a developer's high-end laptop. The Performance panel will show you which frames exceed the 16ms budget and which CSS property is responsible.
For component libraries and design systems, provide animation tokens rather than hardcoded durations. A single --animation-speed-fast: 150ms custom property used across all fast animations lets the entire site's animation timing be adjusted with one change. This also makes it easy to set --animation-speed-fast: 0ms in the prefers-reduced-motion block, which disables all tokenized animations in one declaration.
When animations are driven by user interaction (button clicks, hover, form submission), ensure the animated element receives focus and the animation state is communicated to assistive technologies via aria-busy or a visually hidden live region. An animated loading spinner that is only conveyed through motion is inaccessible to screen reader users. The combination of an animation with an aria-label change or a live region text update covers both visual and assistive technology users.
Animation not playing — fix checklist
- ✓Confirm animation-duration is set to a non-zero value (default is 0s)
- ✓Verify @keyframes name matches animation-name exactly including casing
- ✓Check that the element and all its ancestors do not have display: none
- ✓Inspect Computed styles for animation-play-state and confirm it is running
- ✓Look for overflow: hidden on parent elements that may clip transformed children
- ✓Test with prefers-reduced-motion emulation disabled to rule out accessibility overrides
- ✓Check for a global CSS reset that may be setting animation: none on all elements
- ✓Use the DevTools Animations panel to confirm the browser sees the animation at all
Related guides
Frequently asked questions
Why is my CSS animation not playing even though I can see it in DevTools?
If the animation appears in the DevTools Animations panel but the element is not moving, check the Computed styles for animation-play-state. If it shows paused, a JavaScript inline style or a CSS rule with higher specificity is overriding the default running value. Also check for display: none on the element or any of its ancestors.
Does display:none stop CSS animations?
Yes. Elements with display: none are removed from the layout tree and cannot be animated. When display changes from none to block, the animation begins from computed time, which means it may complete in a single frame if the duration has already elapsed. Use opacity: 0 with pointer-events: none as an alternative that allows animation to play correctly.
How do I check if animation-play-state is being set by JavaScript?
In DevTools Elements panel, right-click the animated element and choose Break on → attribute modifications. This adds a DOM breakpoint that pauses JavaScript execution whenever the element's inline style changes. The call stack will show you exactly which line of JavaScript is setting animationPlayState.
Why does my animation play in CodePen but not in my app?
Your app likely has a global CSS reset, a Tailwind base layer, or another stylesheet that sets animation: none on all elements. Check the Computed styles for the animation property and look at the source column to find which stylesheet is setting it. Override the animation property with higher specificity in your component stylesheet.
Can prefers-reduced-motion stop an animation from playing?
Yes. If your stylesheet contains @media (prefers-reduced-motion: reduce) { .element { animation: none; } } and the user has enabled Reduce Motion in their OS accessibility settings, the browser will honor that rule and disable the animation. Emulate this in DevTools via the Rendering tab to test your reduced-motion fallbacks.
Why does overflow:hidden on a parent break my translate animation?
overflow: hidden clips any content that extends outside the element's border box, including children that are moved by transform. An element animated from translateX(-100%) starts fully outside the parent and is invisible until it enters the parent's bounds. Replace overflow: hidden with overflow-x: clip on the parent, or animate from a starting position that is already inside the container.
Does animation stop when the user switches tabs?
Yes. Browsers use the Page Visibility API to pause CSS animations when a tab is hidden, which conserves battery and CPU. Animations resume when the tab becomes visible again. If your animation does not resume, check for a visibilitychange event listener that sets animationPlayState to paused without setting it back to running on tab focus.
How do I animate an element on mount in React?
Keep the element in the DOM and toggle an animation class rather than conditionally rendering it. Use useEffect with a short setTimeout (or requestAnimationFrame) to add the class after the first render, giving the browser time to compute the initial layout. Toggling an animation class is more reliable than mounting the element and expecting the animation to start from a valid position.
Why does my animation only play once and not repeat?
Check the animation-iteration-count. The default is 1, so the animation plays once and stops. Set it to infinite for a looping animation, or to a specific number like 3 to repeat a set number of times. If you used the shorthand and intended infinite but the animation still stops, verify that infinite is present and not overridden by a later rule.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.