What Is CSS Animation and How Does the Browser Run It?
You describe a few states and the browser works out the frames between them. The states go in a @keyframes rule that names the animation, and one or more animation-* properties attach that rule to an element. Duration, easing, repetition, direction, and playback all get controlled the same way.
Everything lives in the stylesheet, written in CSS. The CSS Animations Module Level 1 defines the rule and the properties, and Level 2 adds animation-composition. None of it needs a script, which is the main thing that separates it from animation driven by JavaScript.
Where the animation runs
web.dev explains that CSS animations are typically handled on the compositor thread, so they keep playing while the main thread is busy with expensive tasks.
In practice that holds for transform and opacity. Animations of layout properties need the main thread.
Support isn’t a worry. The animation shorthand has been Baseline widely available, with support in every major browser since September 2015, according to MDN.
How to Write a CSS Animation
Define the states with a @keyframes rule, then attach the rule to an element with animation-name and animation-duration. Nothing plays until both are in place.
- Name the rule, as in @keyframes slide-in { }
- Add keyframe selectors inside it, either from and to, or percentages such as 0%, 50%, and 100%.
- Write the property values for each state inside its selector.
- Attach the rule with animation-name on the element, using exactly the same name.
- Set animation-duration, because the default is 0s.
@keyframes slide-in {
from { transform: translateX(-40px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.card {
animation: 600ms ease-out slide-in;
}
Leave out the 0% or 100% selector and the browser fills the gap with the element’s computed value.
Percentage selectors are where keyframe rules pay off, since one rule can hold five states or fifty.
Shorthand order and its traps
The shorthand is picky about order. The first time value the parser finds becomes the duration, and the second becomes the delay.
MDN recommends putting the animation name last, because a value that parses as another animation property gets assigned there first.
A missing duration falls back to 0s. The animationstart and animationend events still fire, and nothing visible happens, which is exactly the trap from the top of this article.
Animating custom properties
An unregistered custom property cannot animate, because the browser does not know its type. Register it with @property and it interpolates like any native value.
@property has been Baseline newly available since July 2024, per MDN. The rule needs the syntax and inherits descriptors, plus initial-value unless the syntax is *, or the browser ignores the whole rule.
Animation Properties and Their Default Values
Each animation-* property controls one aspect of playback, and the animation shorthand sets most of them in a single declaration. Anything omitted from the shorthand resets to its initial value.
| Property | Accepted values | Default | Controls |
|---|
| animation-duration | Time such as 600ms | 0s | Length of one cycle |
| animation-timing-function | Keyword, cubic-bezier(), steps(), linear() | ease | Speed between keyframes |
| animation-delay | Time, negative allowed | 0s | Wait before the first cycle |
| animation-iteration-count | Number or infinite | 1 | Number of repeats |
| animation-direction | normal, reverse, alternate, alternate-reverse | normal | Keyframe order per cycle |
| animation-fill-mode | none, forwards, backwards, both | none | Styles before and after the run |
| animation-play-state | running, paused | running | Pause and resume |
| animation-composition | replace, add, accumulate | replace | Blending with the base value |
Looping and direction
An infinite iteration count with alternate direction plays the animation forward, then backward, with no jump at the seam. Pair infinite with a linear timing function and you get the standard setup for loaders, such as spinners built with CSS.
Fill mode and the snap-back problem
With the default fill mode of none, the element returns to its normal styles the moment the last cycle ends. That snap-back surprises almost everyone the first time.
Forwards holds the last keyframe after the run. Backwards applies the first keyframe during the delay, and both does the two together.
MDN documents a side effect of forwards. Animated properties behave as if listed in will-change, and a stacking context created during the animation stays after it finishes.
Running several animations on one element
Separate animations with commas and each gets its own timing. When two animations set the same property, the one declared later wins the cascade.
animation-composition changes that outcome, and it has been Baseline widely available, with support in every major browser since July 2023 (MDN). Take a base of blur(5px) and a keyframe of blur(10px). Replace gives blur(10px), add gives blur(5px) blur(10px), and accumulate gives blur(15px).
The shorthand treats animation-composition as reset-only, so give it its own declaration after the shorthand.
Easing and Timing Functions for CSS Animation
Easing decides how fast an animation moves between keyframes. Keywords cover most cases. cubic-bezier() handles custom curves, steps() produces jumps, and linear() approximates bounces and springs.
The default, ease, is cubic-bezier(0.25, 0.1, 0.25, 1). ease-in is cubic-bezier(0.42, 0, 1, 1) and starts slowly. ease-out, cubic-bezier(0, 0, 0.58, 1), starts fast and slows at the finish, while ease-in-out, cubic-bezier(0.42, 0, 0.58, 1), is slow at both ends.
Ease-out is my pick for micro-interactions such as button feedback, because the response begins instantly.
One detail people miss. The timing function applies between each pair of keyframes, not across the whole animation.
cubic-bezier()
Four numbers define two control points. The x values must stay between 0 and 1, while the y values can go beyond that range, which produces overshoot.
steps()
steps(4, end) splits the animation into four equal intervals and jumps between them with no blending. That suits typing effects that reveal text a character at a time, and sprite sheets that swap one frame per step.
The jump terms are jump-start, jump-end, jump-none, jump-both, start, and end. step-start and step-end are shortcuts for a single step.
linear()
linear() has worked in every major browser since December 2023 and became Baseline widely available in June 2026, according to MDN and the web-features data.
It takes two or more number stops, optional percentages position them, and values outside 0 to 1 are allowed, which is how overshoot and bounce get drawn. Approximating a curve takes many points, so generate them with a tool instead of typing them.
Staggering with animation-delay
Give each element an index through a custom property, then multiply it inside the delay: animation-delay: calc(var(–i) * 80ms).
Element 0 starts at once and element 3 starts 240ms later. Keep the step small relative to the duration, or the sequence drags.
Which Properties Are Safe to Animate?
transform and opacity are safe, because the browser can animate them on the compositor without recalculating layout or repainting. web.dev recommends restricting animations to those two properties where possible.
| Pipeline stage | Example properties | Effect |
|---|
| Composite only | transform, opacity | No layout shift, can run off the main thread |
| Paint | color, background-color, box-shadow | Repaint on every frame |
| Layout | width, height, top, left, margin | Reflow and repaint, can raise CLS |
Cumulative Layout Shift, a Core Web Vital, scores unexpected layout shifts, and a layout shift happens when a visible element changes its start position from one rendered frame to the next, according to web.dev. Its guidance swaps height for transform: scale() and top for transform: translate().
The Chrome for Developers Lighthouse documentation defines a non-composited animation as any animation that triggers the Style, Layout, or Paint steps of the rendering pipeline. Composited animations are excluded from CLS.
Does will-change help?
Rarely, and only as a last resort. MDN warns against applying it to many elements or as premature tuning.
Set it in a stylesheet and the browser treats the element as always about to change, so it holds its preparations far longer than needed. Toggling it from script before and after the change is the safer pattern.
Also, will-change: opacity creates a stacking context up front, which can change how the element looks.
How to measure animation performance
Record while the animation plays. The Chrome DevTools Performance panel shows dropped frames, and Lighthouse lists the DOM nodes whose animations were not composited, with a failure reason for each.
Swap the property first, and add will-change only if the swap is not enough.
How to Make CSS Animation Accessible
Gate decorative motion behind prefers-reduced-motion, and give any animation that starts on its own and runs longer than five seconds a pause control. The first respects a system setting, and the second covers what that setting misses.
MDN describes prefers-reduced-motion as a media feature that detects whether the user asked the device to minimize motion, and it has been Baseline widely available, with support in every major browser since January 2020. It lives in a media query with two values, no-preference and reduce, and the bare query counts as reduce.
Opt-in or opt-out
With opt-in, you write the static state as the default and add motion inside @media (prefers-reduced-motion: no-preference). Opt-out flips that. The motion is the default, and you cancel or soften it inside @media (prefers-reduced-motion: reduce).
W3C documents both patterns in Technique C39, its CSS technique for WCAG 2.3.3. Opt-in wins for new work, because a forgotten rule leaves the page still instead of moving.
Reduce versus remove
Reduced motion does not mean zero change. MDN’s example swaps a scaling animation for a gentle dissolve under the reduce setting, which keeps the feedback and drops the movement.
Be careful with a blanket animation: none inside the reduce block. No animationend event ever fires then, so any script waiting on it stalls. Set a near-zero duration instead when script depends on the event.
What the media query does not cover
prefers-reduced-motion answers WCAG 2.3.3, which targets motion triggered by interaction and sits at Level AAA. Autoplaying and looping content falls under 2.2.2 at Level A, and a system preference is not a pause mechanism.
| Criterion | Level | Trigger | Requirement |
|---|
| 2.2.2 Pause, Stop, Hide | A | Starts automatically, lasts over five seconds, plays beside other content | A way to pause, stop, or hide it |
| 2.3.3 Animation from Interactions | AAA | Motion triggered by user interaction | A way to disable it |
| 2.3.1 Three Flashes or Below Threshold | A | Flashing content | Flashes stay below the seizure thresholds |
Parallax scrolling is the classic case W3C names for 2.3.3, since the motion is rarely required for the content. For 2.2.2, W3C picked five seconds because it is long enough to catch attention but short enough to wait out.
What Are Scroll-Driven Animations and Where Do They Work?
Scroll position replaces the clock. Instead of running on time, the animation’s progress follows how far the page has scrolled, and the animation-timeline property picks the timeline, either scroll() or view().
With scroll(), progress follows how far a scroll container has scrolled, using the nearest ancestor on the block axis by default. With view(), it follows an element’s position inside its scroll container. The animation-range property sets the segment of the timeline where the animation plays.
Chrome for Developers notes that these animations can run off the main thread, which a JavaScript scroll listener cannot match. That makes CSS animations on scroll the better default for progress bars and reveal effects.
Writing a scroll progress bar
Keyframes scale a bar from zero to full width, and the scroll timeline replaces the duration.
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.progress-bar {
position: fixed;
inset: 0 0 auto 0;
height: 4px;
transform-origin: left;
animation: grow-progress linear;
animation-timeline: scroll();
}
Declare animation-timeline after the animation shorthand, or the shorthand resets it. Firefox needs a duration set before it applies the animation, and MDN’s own examples use 1ms for that.
MDN’s animation-duration reference is not consistent with itself, which is annoying. Its syntax block shows auto as the default, while its formal definition, like the animation shorthand page, gives 0s as the initial value. For time-based animations auto behaves like 0s. For scroll-driven animations the difference matters, because auto stretches the animation across the whole timeline.
Where it works today
As of October 2026, Chrome and Edge have supported it since version 115 (Chrome for Developers), and Safari supports it in version 26 (Chrome for Developers).
Firefox is the last engine to switch it on. Stable releases up to Firefox 158 keep it behind the layout.css.scroll-driven-animations.enabled flag, which has existed since Firefox 110. Firefox 159, scheduled for October 27, 2026, enables scroll-driven animations by default, according to MDN’s Firefox 159 release notes and its browser compatibility data. Once that release ships, the feature works in all three engines, and MDN’s Baseline label for animation-timeline should move from limited availability to newly available.
Older Firefox versions stay in use for a while after any release, so keep treating the animation as an enhancement. Keep the content visible by default and apply animation-timeline inside @supports (animation-timeline: view()), so visitors on older browsers see the content without the effect.
Planning for that gap is ordinary cross-browser compatibility work, not a reason to skip the feature.
Scroll-triggered animation is a separate feature
The specification separates two ideas. Scroll-driven animations take their progress from scroll position, while scroll-triggered animations start at a scroll position and then run on time.
MDN now documents an animation-trigger property and an Animation triggers module for the second kind. The feature is experimental and Chromium-only (Chrome 146 and later), with no Firefox or Safari support, so check its compatibility table before relying on it.
Scrolling counts as interaction, so gate both kinds inside prefers-reduced-motion: no-preference.
CSS Animation vs CSS Transitions, the Web Animations API and JavaScript Libraries
Use a transition for a two-state change, CSS animation for multi-step or load-time motion, the Web Animations API when script needs playback control, and a library when sequencing outgrows all three. Pick the lightest tool that covers the job.
| Option | Best for | Control | Main limit |
|---|
| CSS transition | Hover and class-toggle changes | Start and end values | Needs a trigger, no middle keyframes |
| CSS animation | Multi-step motion, loops, load-time effects | Keyframes, timing, fill, direction | CSS alone cannot seek or change speed; that takes the Web Animations API |
| Web Animations API | Script-driven playback | Play, pause, reverse, playbackRate | Needs JavaScript |
| Library such as GSAP | Long sequences, scroll scrubbing | Timelines and plugins | Adds a dependency |
CSS animation or CSS transition
A transition moves an element from state A to state B when something changes, such as a hover or a class toggle. A CSS animation can run from A through B and C to D, and it can start on page load without any trigger.
CSS Animation Rocks (2015) adds a useful edge case. When script keeps changing a value, like the angle of a clock hand, a transition handles each change more gracefully than a keyframe animation.
The Web Animations API
The Web Animations API reaches the same underlying animation model from script. Element.animate(keyframes, options) creates and plays an animation, and MDN lists it as Baseline widely available, with support in every major browser since March 2020.
The returned Animation object offers play(), pause(), reverse(), finish(), and cancel(), plus a playbackRate where negative values reverse the animation and 0 effectively pauses it.
web.dev explains that CSS and Web Animations animations typically run on the compositor thread. When an animation triggers layout or paint, the main thread is needed whether CSS or JavaScript drives it, and that overhead dwarfs any difference between the two.
Libraries and prebuilt keyframes
GSAP is free for commercial use under its standard license since Webflow made the whole toolset free, plugins included. Its ScrollTrigger plugin is the usual choice for scroll scrubbing.
Animate.css, at version 4.1.1, ships ready-made keyframes behind the animate__ prefix and takes durations through –animate-duration. It also respects prefers-reduced-motion.
Prebuilt packs such as Animate.css belong with the CSS animation libraries that skip hand-written keyframes, though its own docs advise against endless loops.
Where CSS animation stops working
- CSS alone cannot seek to a point or change speed. Script can, through the Web Animations API, which also reaches existing CSS animations via getAnimations() and lets you set currentTime or playbackRate.
- Animating to height: auto needs interpolate-size: allow-keywords, which MDN lists as limited availability.
- linear() draws a spring curve, but the curve cannot react to drag velocity.
- Thousands of moving objects outgrow the DOM.
A requestAnimationFrame loop earns its place in that last case, as in canvas animations. For ordinary interface motion it is rarely justified.
Why Is a CSS Animation Not Working?
Most broken animations trace back to a short list of causes. Work through them in this order.
- Match the names. The animation-name value must equal the @keyframes name exactly, including case.
- Confirm a duration above zero is set. Animate.css’s docs flag the same trap when its keyframes are called directly.
- Check the cascade. A more specific rule or a later shorthand can override your declaration, and a shorthand written after a longhand resets that longhand.
- Check the property. display and unregistered custom properties do not interpolate, and MDN keeps an Animatable properties reference.
- Check the motion setting. A reduced-motion rule may be cancelling the animation on your own machine, so switch off Reduce motion in the system settings or emulate the media feature from the Rendering tab in Chrome DevTools.
- Check browser support for the feature in the failing browser.
The Styles pane in browser DevTools settles the third check quickly, because it crosses out every declaration that lost the cascade.
Inspecting animations in browser DevTools
In Chrome DevTools, open More tools, then Animations. The panel captures CSS animations, CSS transitions, web animations, and View Transitions. You can slow down and replay a group, and edit timing, delay, duration, and keyframe offsets.
Firefox’s Animations tab in the Page Inspector draws every animation on a timeline, with a draggable widget that jumps the page to any point. It labels each bar as a CSS animation, a CSS transition, or a Web Animations API animation.
Checking support before debugging code
Check the Baseline label on MDN and the per-version tables on Can I use before blaming your CSS.
Widely available means well established across browsers, while limited availability means some widely used engine lacks the feature.
CSS Animation FAQ
How do you pause, stop, or restart a CSS animation?
Set animation-play-state: paused to freeze it and running to resume. Setting animation to none stops it entirely.
To restart, remove the animation class, read an offset property to force a reflow, then add the class back.
How do you build a loading spinner?
Rotate an element from 0deg to 360deg in a keyframe rule, then apply it with an infinite iteration count. Use a linear timing function, because easing makes the spinner stutter at every turn.
What is the simplest fade-in on page load?
Animate opacity from 0 to 1, with an optional translateY for a slide. Set the fill mode to both, so the element stays hidden during any delay and holds its final state afterward.
Can you make a typing effect without JavaScript?
Yes. Animate the width of a single text line from 0 to 100% with overflow hidden and white-space nowrap, using steps() equal to the character count.
A monospace font keeps each step aligned with one letter.
Do I still need -webkit- prefixes for animations?
No. The unprefixed animation properties and @keyframes work across current browsers. Prefixed copies only serve very old browsers.
How do you detect when an animation ends?
Listen for the animationend event on the animated element. The event’s animationName property tells you which animation finished, and animationstart and animationiteration cover the beginning and each repeat.
An infinite animation never fires animationend.
Which CSS Animations to Audit First
Start with looping and autoplaying animation, because automatic motion is where a Level A WCAG failure is most likely. Performance problems come next, so look for animations on layout properties that drop frames. Scroll-driven effects go last, and they only need a fallback.
Conformance gaps rank first because they exclude users, while dropped frames only degrade the experience. Optional enhancements sit at the bottom, since a missing effect costs nothing.
Gating motion behind prefers-reduced-motion costs some polish, because visitors with the setting on see a plainer page. The plainer page is the intended result.
Rerun the accessibility audit whenever a new animation ships. Firefox 159 turns scroll-driven animations on by default, so the @supports fallback can go once older Firefox versions drop out of your analytics.