CSS Animation Examples — Keyframes, Timing, Fill Mode, and Real UI Motion
Quick answer
💡A CSS animation requires an @keyframes block and an animation property on the target element. The animation shorthand sets duration, timing function, delay, iteration count, direction, fill-mode, and play-state in one line. Use transform and opacity for smooth GPU-composited motion, and always add a prefers-reduced-motion media query for accessibility.
Error symptoms
- ✕
Animation runs once and snaps back to the original state - ✕
Element jumps visibly at the start or end of the animation - ✕
Animation plays on load but never repeats on interaction - ✕
Animation is choppy or drops frames on mobile devices - ✕
Animation name typed correctly in CSS but still does nothing - ✕
Animation works in Chrome but breaks in Safari
Common causes
- •Missing animation-fill-mode so the element reverts to its original state after finishing
- •Animating layout properties like width or top instead of transform
- •@keyframes block defined but animation-name does not match exactly (case-sensitive)
- •animation-duration left at its default of 0s so nothing plays
- •No will-change: transform hint, causing the browser to paint on the CPU
- •Missing -webkit- prefix for Safari versions older than 9
When it happens
- •When adding an entrance animation to a component that should hold its final position
- •When building a loading spinner that needs to loop indefinitely
- •When a hover state triggers an animation that should pause or reverse on mouse-out
- •When deploying to production and noticing jank on lower-end Android devices
- •When a designer hands off a Figma prototype and the CSS translation is missing fill-mode
Examples and fixes
A pure CSS spinner that loops indefinitely using transform: rotate on a GPU-composited layer.
Infinite loading spinner with GPU compositing
❌ Wrong
<div class="spinner"></div>
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e2e8f0;
border-top-color: #6366f1;
border-radius: 50%;
animation: rotate 1s linear infinite;
}
@keyframes rotate {
to { left: 100px; }
}✅ Fixed
<div class="spinner"></div>
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e2e8f0;
border-top-color: #6366f1;
border-radius: 50%;
will-change: transform;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}The broken version references a keyframe name (rotate) that does not exist and animates the left property, which triggers layout recalculation every frame. The fixed version uses the correct keyframe name (spin), animates transform: rotate which is GPU-composited, and adds will-change: transform to promote the element to its own compositor layer before the animation starts. The result is a smooth, jank-free spinner with no layout cost.
A toast notification that slides and fades in while keeping its final position and respecting the OS motion preference.
Entrance fade with fill-mode and reduced-motion guard
❌ Wrong
<div class="toast">Saved!</div>
.toast {
animation: toast-in 200ms ease-out;
}
@keyframes toast-in {
from { opacity: 0; }
to { opacity: 1; }
}✅ Fixed
<div class="toast">Saved!</div>
.toast {
animation: toast-in 200ms ease-out both;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.toast { animation: none; opacity: 1; }
}The broken version has no fill-mode, so the toast snaps back to opacity: 0 the instant the animation ends. Adding both as the fill-mode shortcut applies the from keyframe during any delay and keeps the to keyframe active after the animation completes. The fixed version also animates transform: translateY alongside opacity so the slide is composited on the GPU, and wraps the animation in a prefers-reduced-motion guard so users who experience motion discomfort see the final state instantly without any motion.
A card highlight that pulses three times using steps and pauses on hover.
Hover pause and multi-step pulse
❌ Wrong
<div class="card">Click me</div>
.card {
animation: pulse 600ms ease-in-out;
}
@keyframes pulse {
50% { transform: scale(1.04); }
}✅ Fixed
<div class="card">Click me</div>
.card {
animation: pulse 600ms ease-in-out 3;
will-change: transform;
}
.card:hover {
animation-play-state: paused;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.04); }
100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.card { animation: none; }
}The broken version runs the pulse once and ends mid-transform because the 100% keyframe is absent, leaving the card slightly scaled. The fixed version adds explicit 0% and 100% keyframes so the element returns to its original size, sets the iteration count to 3 so it pulses three times then stops, and uses animation-play-state: paused on hover so the user can interrupt the animation. The will-change hint ensures the scale is composited rather than triggering a repaint each frame.
How CSS animations actually work
A CSS animation has two required pieces: an @keyframes rule that defines what changes happen and at what percentage of the timeline, and an animation property on the element that references those keyframes by name. The name comparison is case-sensitive, so @keyframes Spin and animation-name: spin will not connect. If either piece is missing or misspelled, the browser silently skips the animation without producing an error.
The animation shorthand accepts up to eight values in a specific order: duration, timing-function, delay, iteration-count, direction, fill-mode, play-state, and name. The order matters for the first two time values — the browser assigns the first time value to duration and the second to delay. A common mistake is writing animation: spin 0.5s 1s when the intent was a 1-second animation with a 0.5-second delay; the browser reads it the opposite way.
Keyframe selectors can use from and to as aliases for 0% and 100%, or they can use explicit percentages for multi-step sequences. When you omit the 0% or from keyframe, the browser uses the element's current computed style as the starting point. This works for most entrance animations but can cause unexpected jumps when the element's style is not stable at the moment the animation begins.
The timing-function applies between each pair of keyframes, not across the entire animation. A cubic-bezier or steps() function defined on the @keyframes rule overrides the one on the element for that specific segment. This lets you create animations where the easing is fast in the first half and slow in the second half without needing JavaScript to split them.
Diagnosing animation problems in DevTools
Chrome DevTools includes a dedicated Animations panel. Open it by pressing F12, clicking the three-dot menu at the top right of the panel, selecting More tools, and choosing Animations. The panel records all animations that start while it is open and displays them as a timeline. You can scrub the playhead, slow down the playback to 10% or 25% speed, and inspect the exact easing curve for each property.
If the panel shows the animation but the element appears frozen, check the Computed tab in the Elements panel. Look for animation-play-state: paused — this is the most common reason an animation registers as active but produces no visible motion. JavaScript on the page may be setting element.style.animationPlayState to paused, or the element may have picked up the paused state through a CSS rule. Filter the Computed styles by typing animation to surface all animation-related declarations.
For animations that register as playing but look wrong, switch to the Rendering drawer (F12 → More tools → Rendering) and enable Paint Flashing. Properties like width, height, top, left, margin, and padding cause green flash overlays every frame they change. Seeing a full-page green flash on every animation frame means you are triggering layout on every frame, which is expensive. Replace those properties with transform equivalents: translateX instead of left, translateY instead of top, scaleX instead of width.
On mobile, open Chrome Remote Debugging over USB or use Android Studio's device mirroring to attach DevTools to the device's browser. The Animations panel works the same way remotely. Pay attention to the frame rate indicator in the Performance panel — sustained animation below 60fps on a mid-range device usually points to a non-composited property or a large repaint area.
Fixing the most common animation bugs
The single highest-value fix for any CSS animation is switching from layout-triggering properties to transform and opacity. Animating width, height, top, left, margin, or padding forces the browser to recalculate layout and repaint the affected area on every frame. Animating transform: translate(), transform: scale(), transform: rotate(), or opacity skips both layout and paint — the GPU handles them at the compositor stage. The visual result is identical in almost every case, and the performance improvement on mobile can be dramatic.
For animations that snap back after finishing, add fill-mode: forwards or use the both keyword in the shorthand. forwards applies the final keyframe values after the animation ends. backwards applies the initial keyframe values during any delay before the animation starts. both does both. The most useful value for entrance animations is both because it prevents the element from flashing its original state during a delay and keeps it at the final state permanently.
When an animation needs to run only when a class is added (for example, on component mount in React or Vue), define the animation on the class rather than on the base element. Remove the class with a short JavaScript timeout after the animation-duration plus any delay has elapsed, or listen to the animationend event. Using animationend is more reliable because it fires at the exact moment the animation completes regardless of how long you set the duration.
For cross-browser compatibility, all modern browsers support @keyframes and the animation property without prefixes as of 2016. The one exception is Safari versions older than 9, which require the -webkit- prefix. If your target audience includes older iOS devices, add both the prefixed and unprefixed declarations. Most build tools including PostCSS with autoprefixer handle this automatically when you configure the browserslist target.
Edge cases: display, visibility, and tab focus
Elements with display: none cannot be animated. The browser removes them from the layout tree entirely, so there is no computed geometry to interpolate. If you want to animate an element into view, start with visibility: hidden or opacity: 0 and then transition or animate to the visible state. Alternatively, keep the element in the DOM with its final dimensions already applied, offset it off-screen with transform: translateX(-100%), and animate it into position.
Tab visibility affects running animations. When the user switches to a different tab or the page is hidden by the Page Visibility API, most browsers pause all CSS animations to conserve battery. This is intentional and correct behavior. If your animation controls a UI element that must remain accurate during tab-switching, such as a countdown timer, use JavaScript with requestAnimationFrame and Date.now() deltas rather than pure CSS animation.
The will-change: transform declaration instructs the browser to promote the element to a separate compositor layer before the animation begins. This avoids the one-frame compositor layer creation cost that can cause a visible stutter at animation start on mobile. Apply will-change only to elements that will actually animate — applying it to all elements wastes GPU memory. A practical pattern is to add will-change in a :hover or a JavaScript class that fires just before the animation starts, then remove it with another class after the animationend event.
Multiple animations on the same element are comma-separated in the animation shorthand. Each animation is independent and can have its own duration, delay, and iteration count. Conflicts between animations targeting the same property are resolved by the order of the comma list — the last animation in the list wins for any given frame. This allows you to combine a looping background pulse with a one-shot entrance fade without either overriding the other.
Mistakes that break CSS animations silently
Mismatched keyframe names are by far the most common silent failure. The browser does not throw an error when animation-name references a nonexistent @keyframes block — it simply does nothing. Always confirm the name appears in exactly the same casing in both places. If you are using a CSS preprocessor like SCSS with a mixin that generates animation names dynamically, print the compiled output and verify the name before debugging further.
Animating background-color instead of opacity causes a repaint on every frame because color changes cannot be composited by the GPU. If you need a color change effect, consider crossfading between a white and colored overlay using opacity on the overlay, or using a box-shadow with a color transition (also repaint-expensive but confined to the shadow area). For performance-critical animations, prefer opacity and transform exclusively and achieve color effects through color stops in gradients or SVG elements.
Forgetting animation-fill-mode on entrance animations is a classic mistake. The element appears, the animation plays, and then the element vanishes or snaps to its pre-animation position. This happens because fill-mode defaults to none, meaning the animation applies only while it is actively running. Setting fill-mode to forwards or both is almost always the correct choice for entrance animations.
Using animation: none to reset an animation in JavaScript does not always work as expected when the same animation needs to be retriggered. The browser sees the same element with the same animation and may skip the restart. The correct pattern is to remove the class that applies the animation, force a style recalculation with void element.offsetWidth, and then re-add the class. This flushes the animation state and lets it restart from the beginning.
Best practices for production animations
Always include a prefers-reduced-motion media query for every animation that moves content across the screen. The @media (prefers-reduced-motion: reduce) block should either disable the animation entirely with animation: none or replace it with a simple opacity fade that communicates the same state change without vestibular-disturbing motion. Screen reader users and people with vestibular disorders depend on this. In some jurisdictions, failing to honor this preference in certain digital products can create accessibility liability.
Limit the number of simultaneously animating elements on mobile. Each animated element that is on its own compositor layer consumes GPU texture memory. A page with dozens of independently animating components can exhaust the GPU memory budget on low-end phones, causing the browser to fall back to software rendering and drop frames. A good rule is to animate no more than five to ten elements simultaneously on a given screen, and to strip will-change declarations from elements once their animations complete.
For animations that are triggered by JavaScript events, define the animation in CSS and control it by toggling a class. This keeps the animation logic in CSS where it belongs, lets DevTools inspect it with the Animations panel, and avoids the overhead of JavaScript-driven style mutations on every animation frame. Use the Web Animations API (element.animate()) only when you need precise programmatic control over playback, such as scrubbing an animation to match a scroll position.
Test animations on real devices, not just desktop simulators. Modern iPhones and Pixel phones are fast enough to make any animation feel smooth, but mid-range Android devices running at 60Hz with a limited GPU are where most animation bugs manifest. Install Chrome on an Android device, enable USB debugging, and use chrome://inspect to attach DevTools for remote profiling. The Performance panel will show you exactly which frames are dropping and which properties are causing the slowdown.
Animation quick-start checklist
- ✓Define @keyframes with a name that matches animation-name exactly (case-sensitive)
- ✓Set a non-zero animation-duration (default is 0s and nothing will play)
- ✓Add fill-mode: both for entrance animations that should hold their final position
- ✓Animate only transform and opacity for GPU-composited, jank-free motion
- ✓Add will-change: transform just before the animation starts for complex elements
- ✓Include @media (prefers-reduced-motion: reduce) to disable or replace motion
- ✓Use animation-play-state: paused on :hover to allow user-controlled pausing
- ✓Test on a real mid-range Android device to catch GPU memory and frame-rate issues
Related guides
Frequently asked questions
What is the minimum CSS needed to run an animation?
You need an @keyframes rule with at least one keyframe and an animation property on the element that sets both the name and a non-zero duration. Example: animation: spin 1s linear infinite paired with @keyframes spin { to { transform: rotate(360deg); } }. Everything else has usable defaults.
Why does my animation snap back after it finishes?
The fill-mode is set to none, which is the default. The animation applies only while actively running, then the element reverts to its original computed style. Set animation-fill-mode: forwards or add both as the sixth value in the animation shorthand to keep the final keyframe values applied after the animation ends.
What is the difference between fill-mode forwards and both?
forwards keeps the final keyframe values after the animation ends. backwards applies the initial keyframe values during any animation-delay period before the animation starts, preventing the element from showing its base style during the delay. both does both. For most entrance animations, both is the correct choice.
Which CSS properties are safe to animate for performance?
transform (translate, rotate, scale, skew) and opacity are the two GPU-composited properties. They skip layout and paint, so the browser handles them entirely on the compositor thread at no CPU cost. Animating width, height, top, left, margin, or padding triggers full layout and paint on every frame, which is expensive on mobile.
How do I pause a CSS animation on hover?
Add animation-play-state: paused to the hover selector. Example: .card:hover { animation-play-state: paused; }. The animation stops at its current frame when the cursor enters the element and resumes from the same frame when the cursor leaves. This requires no JavaScript.
How do I run two animations on the same element?
Comma-separate them in the animation property. Example: animation: spin 1s linear infinite, pulse 2s ease-in-out 3. Each animation is independent with its own timing values. If both target the same CSS property, the last animation in the list wins for each frame.
What does will-change: transform do and when should I use it?
It hints to the browser to promote the element to a separate GPU compositor layer before the animation starts, eliminating the one-frame stutter that can occur on animation start. Use it on elements you are about to animate, ideally added just before the animation begins and removed in the animationend handler. Avoid applying it globally as it wastes GPU memory.
Does Safari need vendor prefixes for CSS animations?
Safari 9 and newer support @keyframes and animation without the -webkit- prefix. Versions older than Safari 9 require @-webkit-keyframes and -webkit-animation. If your analytics show significant Safari below version 9 traffic, add both prefixed and unprefixed declarations. PostCSS with autoprefixer handles this automatically.
How do I restart a CSS animation from JavaScript?
Remove the class that applies the animation, force a style recalculation with void element.offsetWidth, then re-add the class. The style recalculation flushes the animation state. Using setTimeout with 0ms delay is less reliable because it may be batched by the browser before the flush completes.
What is animation-timing-function steps() used for?
steps(n) divides the animation into n discrete jumps instead of a smooth curve, creating a frame-by-frame appearance. It is commonly used for sprite sheet animations where you want to step through background-position values without any interpolation between frames. steps(1, end) is equivalent to a single jump at the very end.
All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-06.