1CSS Keyframe Animations
▶
CSS keyframe animations give you precise, declarative control over how elements change their appearance over time. Unlike CSS transitions, which react to state changes between two values, keyframe animations run independently — they can loop, reverse, pause, and jump through as many intermediate visual states as you need. The mechanism works in two distinct steps: first you define the animation sequence using an @keyframes rule, then you attach that sequence to one or more elements using the animation family of properties. Understanding each part of this system and how they interact gives you the building blocks for everything from a simple fade-in to a complex, multi-stage motion sequence.
Defining Animations with @keyframes
Every CSS keyframe animation begins with an @keyframes rule. This rule acts as a named timeline — a script that describes what CSS properties should look like at various points during the animation's cycle. You give the rule a unique identifier (the animation name), and later reference that name from the element you want to animate.
The simplest form uses the from and to keywords, which are shorthand aliases for 0% and 100% respectively. They are appropriate when you only need to describe a starting state and an ending state:
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
This defines an animation called fadeIn that transitions an element from fully transparent to fully opaque. Any animatable CSS property can be changed between keyframe stops — opacity, transform, color, background-color, width, font-size, and many others. The browser automatically interpolates (calculates the in-between values) for each frame of the animation between the stops you define.
When you need finer control — for example, bouncing, pulsing, or multi-step color changes — you add intermediate percentage stops. These can be placed at any point in the 0–100% range:
@keyframes bounce {
0% { transform: translateY(0); }
25% { transform: translateY(-30px); }
50% { transform: translateY(0); }
75% { transform: translateY(-15px); }
100% { transform: translateY(0); }
}
Here the element drops back to its original position at 50% before bouncing a smaller amount at 75%, producing a realistic diminishing bounce. Multiple properties can be animated simultaneously within the same keyframe stop, and you can mix and match which properties appear at which stops — any property not mentioned at a given stop is interpolated continuously from the nearest stops where it does appear.
You can also group percentage values that share the same styles by comma-separating them on a single line:
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.1); }
}
Attaching Animations to Elements
Once you have defined a @keyframes rule, you attach it to an element using the animation-name property. Without specifying a duration, the animation completes in zero seconds and is invisible, so animation-duration is always required in practice:
.logo {
animation-name: fadeIn;
animation-duration: 1.5s;
}
Duration is expressed in seconds (s) or milliseconds (ms). 1.5s and 1500ms are equivalent. Sub-second values like 400ms are common for UI micro-interactions, while longer values like 3s or 5s suit ambient or decorative animations.
All individual animation properties can be combined into the animation shorthand, whose syntax follows this order:
animation: name duration timing-function delay iteration-count direction fill-mode play-state;
A practical example:
.hero-banner {
animation: slideIn 0.8s ease-out 0.2s 1 normal forwards running;
}
Not every value needs to be specified — any omitted values fall back to their defaults. The two most commonly required values are the name and duration, and the rest are tuned as needed.
Controlling Iteration and Repetition
The animation-iteration-count property controls how many times the animation cycle plays. A plain integer such as 1 (the default) plays the animation once and stops. A value of 3 runs it three full times. Fractional values are also valid: 2.5 plays two full cycles and then stops halfway through the third, leaving the element at the state that corresponds to the 50% keyframe:
.spinner {
animation-name: rotate;
animation-duration: 1s;
animation-iteration-count: 3;
}
.loading-ring {
animation-name: rotate;
animation-duration: 1s;
animation-iteration-count: infinite;
}
The infinite keyword causes the animation to run continuously until the animation is paused, the element is removed from the DOM, or the animation-name is unset. It is the standard choice for loaders, spinners, and ambient background effects.
animation-delay works in conjunction with iteration count to control when the looping begins. A delay of 2s means the animation waits two full seconds before its first iteration starts; subsequent iterations follow immediately one after another with no additional delay between them. This is useful for staggering animations across a list of sibling elements:
.card:nth-child(1) { animation: popIn 0.4s ease both; animation-delay: 0s; }
.card:nth-child(2) { animation: popIn 0.4s ease both; animation-delay: 0.1s; }
.card:nth-child(3) { animation: popIn 0.4s ease both; animation-delay: 0.2s; }
Animation Direction
The animation-direction property determines the order in which keyframe stops are visited on each iteration. There are four keyword values:
| Value | Behavior | Typical Use Case |
|---|---|---|
normal |
Plays 0% → 100% on every iteration (default) | Standard forward animations (fade-in, slide-in) |
reverse |
Plays 100% → 0% on every iteration | Fade-out or slide-out effects using existing keyframes |
alternate |
Plays 0% → 100% on odd iterations, 100% → 0% on even iterations | Ping-pong loops (breathing pulse, swinging pendulum) |
alternate-reverse |
Plays 100% → 0% on odd iterations, 0% → 100% on even iterations | Same ping-pong effect but starting from the end state |
The alternate value is especially valuable because it eliminates the jarring visual jump that happens when a looping animation with normal direction snaps instantly from its 100% state back to its 0% state at the start of each new iteration. With alternate, the return journey is smooth:
@keyframes breathe {
from { transform: scale(1); opacity: 0.8; }
to { transform: scale(1.08); opacity: 1; }
}
.orb {
animation: breathe 3s ease-in-out infinite alternate;
}
Note that when using alternate, the effective perceived period of a full round-trip is twice the animation-duration, since each direction takes one full duration.
Fill Modes
By default, an animation only affects an element's appearance while it is actively running. Before it starts (during its delay period) and after it ends, the element displays its original styles as defined by the rest of the stylesheet. The animation-fill-mode property overrides this behavior:
| Value | Before animation (during delay) | After animation completes |
|---|---|---|
none |
Original styles | Original styles (default) |
forwards |
Original styles | Styles from the last keyframe |
backwards |
Styles from the first keyframe | Original styles |
both |
Styles from the first keyframe | Styles from the last keyframe |
forwards is extremely common when an animation is used to introduce an element — for example, fading in a modal. Without forwards, the element would flash back to opacity: 0 the moment the animation finishes. With forwards, the final opacity: 1 is retained:
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.modal {
animation: fadeIn 0.5s ease-out forwards;
}
backwards is useful when an element has an animation-delay and you want it to be invisible (or otherwise at its starting state) during that waiting period rather than briefly flashing its normal styles before the animation begins. both combines both behaviors and is generally the safest all-purpose fill mode for intro animations with a delay.
Timing Functions and Delay
The animation-timing-function property controls the rate of change between keyframe stops — whether the interpolation is constant, starts slow and ends fast, or follows a completely custom curve. The built-in keyword values map to predefined cubic Bézier curves:
| Keyword | Cubic Bézier Equivalent | Feel |
|---|---|---|
ease |
cubic-bezier(0.25, 0.1, 0.25, 1) |
Starts slow, speeds up, then eases out (default) |
linear |
cubic-bezier(0, 0, 1, 1) |
Constant speed throughout |
ease-in |
cubic-bezier(0.42, 0, 1, 1) |
Starts slow, accelerates toward the end |
ease-out |
cubic-bezier(0, 0, 0.58, 1) |
Starts fast, decelerates toward the end |
ease-in-out |
cubic-bezier(0.42, 0, 0.58, 1) |
Slow at both ends, fast in the middle |
For total control, you can supply a cubic-bezier(x1, y1, x2, y2) value directly. Tools like the Firefox DevTools animation panel or cubic-bezier.com let you design custom curves visually.
The steps() function is a fundamentally different timing model. Instead of smooth interpolation, it divides the animation into a fixed number of discrete jumps, making it ideal for sprite-sheet animation or any effect where you want a deliberate stepped appearance rather than a smooth one:
@keyframes sprite-walk {
from { background-position: 0 0; }
to { background-position: -800px 0; }
}
.character {
width: 100px;
height: 100px;
background-image: url('walk-spritesheet.png');
animation: sprite-walk 0.8s steps(8) infinite;
}
Here steps(8) jumps through 8 discrete frames across the 800px-wide sprite sheet, with each step revealing exactly the next 100px-wide character pose. The optional second argument, start or end (default), controls whether the jump happens at the beginning or end of each step interval.
Negative values for animation-delay are a powerful and often overlooked feature. A negative delay such as -1s tells the browser to start the animation as if it had already been playing for one second. This means the animation begins immediately but partway through its cycle. It is particularly effective for staggering looping animations so they appear to be at different phases of the same rhythm:
.wave-dot:nth-child(1) { animation: wave 1.2s ease-in-out infinite; animation-delay: 0s; }
.wave-dot:nth-child(2) { animation: wave 1.2s ease-in-out infinite; animation-delay: -0.4s; }
.wave-dot:nth-child(3) { animation: wave 1.2s ease-in-out infinite; animation-delay: -0.8s; }
Each of the animation sub-properties — animation-name, animation-duration, animation-delay, animation-timing-function, animation-iteration-count, animation-direction, animation-fill-mode, and animation-play-state — accepts a comma-separated list of values. This allows multiple independent animations to be applied to the same element, each with its own settings:
.notification-icon {
animation-name: spin, pulse;
animation-duration: 2s, 1s;
animation-timing-function: linear, ease-in-out;
animation-iteration-count: infinite, infinite;
animation-direction: normal, alternate;
}
The first set of values applies to spin and the second to pulse. If the lists differ in length, the shorter list wraps around (cycles) to fill the remaining animations.
Play State and Pausing Animations
The animation-play-state property has two possible values: running (the default) and paused. When set to paused, the animation freezes at its current point in the cycle — it does not reset to the beginning. Switching it back to running resumes from exactly where it stopped.
This makes it trivially easy to add user-controlled animation pausing without JavaScript timers or manual state tracking. A CSS-only approach using :hover:
.spinner {
animation: rotate 1.5s linear infinite;
}
.spinner:hover {
animation-play-state: paused;
}
For more complex interactions — such as a play/pause button — you can toggle a class with JavaScript and let CSS handle the rest:
/* CSS */
.animated-element {
animation: drift 4s ease-in-out infinite alternate;
}
.animated-element.paused {
animation-play-state: paused;
}
/* JavaScript */
const el = document.querySelector('.animated-element');
const btn = document.querySelector('#pause-btn');
btn.addEventListener('click', () => {
el.classList.toggle('paused');
});
This pattern is lightweight, accessible to CSS-only inspection tools, and avoids the pitfalls of resetting an animation by removing and re-adding the property. Because paused freezes the animation in-place rather than resetting it, the visual result is always coherent — there is no flash or jump when the user toggles playback.
Understanding play state alongside the full suite of @keyframes definition, attachment properties, iteration control, direction, fill mode, and timing functions gives you a comprehensive and flexible toolkit for creating polished, production-quality CSS animations without reaching for a JavaScript animation library.