CSS Animations, Keyframes & Performance
|
This section documents general HTML5 and CSS concepts — it is not tied to any specific framework or library. This content was generated with the assistance of AI. Verify it against current MDN documentation and browser-support tables (caniuse.com) before relying on it in production, since HTML/CSS features and browser support continue to evolve. |
CSS transitions (covered on Transitions) animate an element smoothly
between two states — typically triggered by a state change such as :hover or a class toggle. The
animation property and the @keyframes rule go further: they let you define a self-contained, multi-step
sequence that can run automatically, loop indefinitely, and move through as many intermediate states as you
like, without needing an external trigger. This page covers the full animation shorthand and its
sub-properties, the @keyframes syntax, several worked examples, the modern
individual-transform and scroll-driven-animation additions, and closes with a dedicated section on how to
keep animations performant.
The animation Property and Its Sub-Properties
To create a CSS animation sequence, you style the element you want to animate with the animation shorthand
property or its individual sub-properties. These control the timing, duration, and other playback details of
the animation — they do not by themselves define what the animation looks like. The visual appearance at
each point in time is controlled separately, by the @keyframes rule (covered in the next section).
| Property | Values accepted | Description |
|---|---|---|
|
Set of values (see below) |
A shorthand for combining all the sub-properties below into a single declaration. |
|
Seconds or milliseconds |
Time before the animation starts. |
|
|
Whether the animation plays forward, backward, or alternates between forward and backward on successive iterations. |
|
Seconds or milliseconds |
The length of time for one animation cycle. |
|
|
How the animation applies styles to its target before it starts and/or after it finishes. |
|
|
The number of times the animation should play before stopping. |
|
One or more |
The name(s) of the |
|
|
Whether the animation is currently running or paused. |
|
|
The timeline used to drive the animation’s progress: the default document timeline, a scroll-driven timeline, a view-driven timeline, or a custom named timeline (see Scroll-Driven Animations below). |
|
|
The rate of progression of the animation throughout each cycle’s duration. |
Shorthand Order
When using the animation shorthand, its values are read in a specific order:
-
Duration — time taken for one animation cycle.
-
Timing function — rate of change over time.
-
Delay — time before the animation starts.
-
Iteration count — number of cycles.
-
Direction — play direction (forward, backward, or alternating).
-
Fill mode — styling applied before/after the animation.
-
Play state — whether the animation is running or paused.
-
Name — the
@keyframesidentifier to apply.
Default values apply to any sub-property that is omitted, except for duration and name, which are required for the animation to actually run. For example:
.box {
animation: 3s ease-in 1s 2 reverse both paused slidein;
}
Reading that value left to right: 3s is the duration, ease-in the timing function, 1s the delay, 2
the iteration count, reverse the direction, both the fill mode, paused the play state, and slidein
the @keyframes name.
The @keyframes Rule
While the animation property configures when and how many times an animation runs, the @keyframes
rule dictates the visual behavior of the animated element at specific points throughout the sequence.
Keyframes are positioned using percentages: 0% marks the start of the animation and 100% marks its end.
The start and end points can also be written as the keywords from and to, which are equivalent to 0%
and 100% respectively, and are optional — if a point isn’t explicitly defined, the browser falls back to
the element’s current computed value for that property.
A simple two-point keyframe animation, using from/to, that fades a blur() filter in over the sequence:
@keyframes blur-animation {
from {
filter: blur(0px); /* no blur at the beginning */
}
to {
filter: blur(10px); /* blurred effect at the end */
}
}
div {
width: 200px;
height: 200px;
background-color: lightblue;
animation: blur-animation 2s ease-in-out infinite alternate;
}
A @keyframes block is not limited to two points — percentage steps let you define as many intermediate
states as the effect requires, as shown in the shake example further below.
Worked Examples
The examples below build up from a simple state change to a looping animation and a multi-step effect.
Hover-Menu Transition (recap)
Transitions covers this exercise in depth: animating a sidebar menu’s
background-color on :hover via the transition shorthand, then layering in a font-weight and image-size
change on the same hover trigger. The key performance lesson from that exercise carries over directly to
animation-based effects too: name the specific properties you animate rather than relying on all, since
all forces the browser to watch every animatable property for changes instead of only the ones that
actually move.
ul li a {
transition: font-weight 0.5s; /* only font-weight is watched */
}
ul li a img {
transition: width 0.5s, height 0.5s; /* only width/height, not "all" */
}
Staggered Slide-in with @starting-style
CSS transitions do not run by default on an element’s very first style update, or when its display changes
from none to another value — there is no "before" state to transition from. The @starting-style rule
solves this by defining the styles an element should be considered to have before its first real update, so
that the transition to its final state actually animates.
@starting-style can be used standalone or nested inside a ruleset:
/* standalone form */
@starting-style {
ul li {
opacity: 0;
transform: translateX(-280px);
}
}
/* final (default) state, with the properties above now transitioning */
ul li {
transition:
background-color 0.5s ease-in-out,
opacity 0.5s ease-in-out,
transform 0.7s ease-in-out;
opacity: 1;
transform: translateX(0px);
}
@starting-style is particularly useful for entry/exit transitions on elements that pop to the front (popovers,
modal dialogs), and for elements toggling to/from display: none or being added to/removed from the DOM.
To turn this into a staggered slide-in — where each list item animates slightly after the previous one,
instead of all moving simultaneously — add a per-item transition-delay using :nth-child():
ul li:nth-child(1) { transition-delay: 0.1s; }
ul li:nth-child(2) { transition-delay: 0.2s; }
ul li:nth-child(3) { transition-delay: 0.3s; }
Each list item then slides in from the left and fades in, cascading one after another rather than as a single block.
CSS Spinner
A loading spinner is one of the most common uses of a @keyframes animation, and, as covered in
Optimize Animation Performance below, is generally cheaper than an equivalent .gif or JavaScript-driven
alternative. The transform: rotate() property is used to spin the element a full circle, ending exactly
where it started so the loop is seamless:
@keyframes load-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
img.spinner {
width: 50px;
height: 50px;
animation: load-spinner 1s linear infinite;
}
Using linear as the timing function is important here — an ease function would cause the rotation to
visibly speed up and slow down at the seams between loop iterations, breaking the illusion of constant
spinning.
Shake Effect
A shake effect (for example, on a menu icon on :hover) is a good illustration of a keyframe animation with
more than two steps. Instead of a single from/to pair, percentage steps alternate the transform back and
forth to create the shaking motion:
@keyframes shake {
0% { transform: translateX(0); }
25% { transform: translateX(-5px) rotate(5deg); }
50% { transform: translateX(5px) rotate(-5deg); }
75% { transform: translateX(-5px) rotate(5deg); }
100% { transform: translateX(0); }
}
ul li:hover a img {
width: 24px;
height: 24px;
animation: shake 0.4s ease;
}
Because the animation is only applied on :hover and has a short, finite animation-duration (with the
default animation-iteration-count: 1), it plays once per hover rather than looping.
Modern Additions
Modern CSS has continued to expand what animations can express and how efficiently they run, without requiring JavaScript.
Individual Transform Properties
CSS now supports translate, rotate, and scale as individual properties, alongside the combined
transform property. Previously, every transform — however simple — had to be written through the single
transform property:
@keyframes rotate-old {
0% { transform: rotate(0deg); }
100% { transform: rotate(45deg); }
}
With individual transform properties, the same animation is simpler to write and reason about:
@keyframes rotate-new {
0% { rotate: 0deg; }
100% { rotate: 45deg; }
}
Because translate, rotate, and scale are now separate properties, they can also be transitioned or
animated independently of one another (and independently of a transform value applied through a different
rule), which makes combining multiple simultaneous transform-like effects considerably easier to maintain.
transition-behavior: allow-discrete
Traditional CSS transitions and animations only work smoothly on properties with continuously interpolatable
values, such as opacity or transform. Discrete properties — ones that flip between distinct values with
nothing meaningful in between, such as display (none versus block) — could not be transitioned at all
by default; they simply flipped instantly.
The transition-behavior property changes this: with transition-behavior: allow-discrete, a discrete
property is allowed to participate in a transition, flipping to its new value at the midpoint (50%) of the
transition’s duration instead of instantly at the start:
.element {
transition: display 0.5s ease-in-out;
transition-behavior: allow-discrete;
}
This is especially useful for elements such as popovers, native <dialog> elements, select menus, and custom
components that need to animate in and out of existence (including toggling to/from display: none) rather
than popping abruptly.
Scroll-Driven Animations
Scroll-driven animations tie an animation’s progress directly to a scroll position, instead of to elapsed
time — common uses include parallax effects and reading-progress indicators. Historically, this required
listening to scroll events on the main thread, which was prone to jank because the animation logic competed
with everything else running on that thread.
The animation-timeline property, combined with the scroll() and view() functions, lets the browser drive
these animations natively and off the main thread instead:
-
animation-timeline: scroll()ties the animation’s progress to the scroll position of a scrolling container. -
animation-timeline: view()ties the animation’s progress to the animated element’s own position as it moves through the visible viewport (useful for "animate in as it scrolls into view" effects).
A scroll-driven progress bar, using scroll():
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
#progress {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 1em;
background: red;
transform-origin: 0 50%;
animation: grow-progress auto linear;
animation-timeline: scroll();
}
Note the auto value used in place of an explicit animation-duration here — with a scroll-driven timeline,
the animation’s "duration" is the scrollable distance itself, not a fixed span of time.
Optimize Animation Performance
A smooth animation is one the browser can produce within its frame budget — roughly 16 ms per frame at 60 frames per second. The techniques below, ordered from the most broadly applicable to the most specific, keep animations inside that budget.
-
Name the specific properties you animate, instead of
all. Bothtransitionand keyframeanimation`s default to watching every animatable property when none is named. Declaring only the properties that actually change (`transition: background-color 0.5s ease-in-out, opacity 0.5s ease-in-outrather thantransition: all 0.5s) lets the browser skip the work of checking every other property on the element for changes. -
Prefer keyframe animations over
.giffiles or JavaScript-driven animations for effects like loading spinners. A CSS@keyframesanimation is typically far lighter than an equivalent animated.gif(which must decode and redraw every frame as image data) or a JavaScript animation loop (which runs on the main thread and must repeatedly recompute styles), depending on the application’s architecture. -
Respect
prefers-reduced-motionfor accessibility. Repetitive motion, parallax, and flicker effects can cause real discomfort — including nausea — for users with vestibular disorders. Theprefers-reduced-motionmedia query reflects an operating-system-level accessibility setting, with two values:reduceandno-preference. Gate non-essential animation behind it:.animation { position: absolute; top: 150px; left: 150px; } @media (prefers-reduced-motion: no-preference) { .animation { animation: move-around 1s 0.3s linear infinite both; } } @keyframes move-around { from { transform: translate(-50px, -50px); } to { transform: translate(50px, 100px); } }With this pattern, a user who has enabled "Reduce motion" (macOS: System Settings > Accessibility > Display; Windows: Settings > Ease of Access > Display > Show animations) simply never receives the animation at all, rather than receiving a "toned down" version of it.
-
Prefer
transformandopacityfor the properties you actually animate. These two properties can typically be updated by the browser’s compositor on the GPU, without re-running layout or repaint on the main thread. Most other animatable properties (colors,filter, etc.) require at least a repaint, which is more expensive but still far cheaper than layout. -
Avoid animating layout-triggering properties, such as
width,height,top,left, ormargin. Changing these forces the browser to recompute the position and size of the animated element and potentially every affected sibling (a "layout" or "reflow" pass) on every single frame of the animation, which is the most expensive category of rendering work and the most common cause of visibly janky animations. Where possible, express the same visual effect withtransforminstead — for example,transform: translate()instead of animatingtop/left, ortransform: scale()instead of animatingwidth/height. -
Use
will-changesparingly, as a hint, not a fix. Declaringwill-change: transform(orwill-change: opacity) tells the browser in advance that an element is about to be animated, letting it promote the element to its own GPU layer ahead of time rather than doing so reactively when the animation starts. Overusingwill-changeon many elements (or leaving it applied permanently instead of toggling it on shortly before the animation and removing it afterward) consumes GPU memory for layers that are not actually animating, which can hurt performance instead of helping it..card { transition: transform 0.3s ease; } .card:hover { will-change: transform; transform: translateY(-4px); }