
CSS Keyframe Animations: From Basics to Advanced Techniques
A loading spinner that rotates forever. A notification badge that pulses gently to catch your eye. A hero headline that slides in word by word. All of these are keyframe animations, and all of them can be built in plain CSS without a single line of JavaScript.
Keyframes are one of the oldest animation tools in CSS, but they've grown far more capable over the years. Custom properties let you parameterise them, animation-composition lets you layer them, and the individual transform properties stop them from fighting each other. Yet many developers still only use the basics, then reach for a JavaScript library the moment something gets slightly complicated.
In this guide, I'll start with the fundamentals of @keyframes and the animation properties, then work up to advanced techniques like staggering, pausing on demand, combining multiple animations, and building reusable, configurable motion.
Anatomy of a Keyframe Animation
Every keyframe animation has two parts:
- An
@keyframesrule that describes what changes, and at which points. - One or more animation properties on an element that say how to play it.
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.15);
opacity: 0.7;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.status-dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
background: #34d399;
animation: pulse 1.6s ease-in-out infinite;
}
The percentages are keyframe selectors. 0% is the start and 100% is the end; from and to are aliases for them. You can use as many intermediate stops as you like.
A few rules that save debugging time:
- If you omit
0%or100%, the browser uses the element's current computed value for that property. That means a keyframes rule with onlytoanimates from wherever the element currently is. - Keyframes with identical content can be grouped:
0%, 100% { ... }. - Properties that can't be interpolated, like
displayin most contexts, switch at the midpoint or at the keyframe boundary rather than animating smoothly. !importantinside a keyframe is ignored.
Because 0% and 100% are identical in the pulse example, it can be written more compactly:
@keyframes pulse {
50% {
transform: scale(1.15);
opacity: 0.7;
}
}
The start and end states fall back to the element's own styles, which are scale(1) and opacity: 1.
The Animation Properties
The animation shorthand bundles eight longhands. Knowing each one individually is what unlocks advanced work.
| Property | What it controls | Common values |
|---|---|---|
animation-name | Which @keyframes to run | pulse, none |
animation-duration | Length of one cycle | 300ms, 1.6s |
animation-timing-function | Easing between keyframes | ease, linear, cubic-bezier(), steps() |
animation-delay | Wait before starting | 0s, 200ms, -1s |
animation-iteration-count | How many cycles | 1, 3, infinite |
animation-direction | Play order | normal, reverse, alternate, alternate-reverse |
animation-fill-mode | Styles before and after | none, forwards, backwards, both |
animation-play-state | Running or paused | running, paused |
The shorthand order is flexible, with one rule: the first time value is the duration and the second is the delay.
.toast {
animation: slide-up 400ms cubic-bezier(0.2, 0.8, 0.2, 1) 150ms both;
}
Here 400ms is the duration, 150ms is the delay, and both is the fill mode.
Fill Mode, Demystified
animation-fill-mode confuses more people than any other property, so it's worth being precise.
none: before the animation starts (during a delay) and after it ends, the element shows its normal styles.forwards: after the animation ends, the element keeps the styles from the last keyframe.backwards: during the delay, the element already shows the styles from the first keyframe.both: combines the two.
For entrance animations with a delay, you almost always want both. Without backwards, an element that should fade in from opacity: 0 is visible during the delay, then snaps invisible and fades in, which looks like a glitch.
Timing Functions Per Keyframe
The timing function applies between each pair of keyframes, not across the whole animation. You can even change it per segment by setting animation-timing-function inside a keyframe:
@keyframes bounce {
0%,
100% {
transform: translateY(0);
animation-timing-function: cubic-bezier(0.3, 0, 0.7, 0.2);
}
50% {
transform: translateY(-2rem);
animation-timing-function: cubic-bezier(0.3, 0.8, 0.7, 1);
}
}
The ball decelerates on the way up and accelerates on the way down, which reads as gravity.
For more organic motion, the linear() easing function lets you define custom curves with many points, which is how you get spring and bounce feels without JavaScript:
.drawer {
animation: open 600ms linear(0, 0.5 15%, 0.95 30%, 1.05 45%, 0.99 60%, 1) both;
}
linear() is supported in all modern browsers. Online generators can convert a spring configuration into a linear() string for you.
Stepped Animations
steps() jumps between values instead of interpolating, which is perfect for sprite sheets and typewriter effects:
.typewriter {
width: 22ch;
font-family: Menlo, monospace;
white-space: nowrap;
overflow: hidden;
border-right: 2px solid currentColor;
animation:
typing 2.2s steps(22) both,
caret 0.8s step-end infinite;
}
@keyframes typing {
from {
width: 0;
}
}
@keyframes caret {
50% {
border-color: transparent;
}
}
The typing animation reveals the text one character at a time because the width grows in 22 discrete steps of 1ch each in a monospace font. The caret blinks independently. Note that two animations are listed, separated by a comma, which is our first taste of combining animations.
Intermediate Techniques
Multiple Animations on One Element
Any animation longhand accepts a comma-separated list. The values are matched by position:
.badge {
animation-name: pop-in, wiggle;
animation-duration: 300ms, 1.2s;
animation-delay: 0s, 1s;
animation-iteration-count: 1, infinite;
animation-fill-mode: both, none;
}
The badge pops in once, then after one second starts wiggling forever.
When two animations target the same property, the later one in the list wins by default. That's a problem when both want to use transform. The next two techniques solve it.
Individual Transform Properties
CSS has standalone translate, rotate, and scale properties. Because they're separate properties, animations on each don't overwrite each other:
@keyframes float {
50% {
translate: 0 -0.5rem;
}
}
@keyframes spin {
to {
rotate: 1turn;
}
}
.planet {
animation:
float 3s ease-in-out infinite,
spin 12s linear infinite;
}
The planet bobs up and down while spinning, and neither animation cancels the other. With a single transform property, you'd have to bake both motions into one set of keyframes. These individual properties are applied in a fixed order: translate, then rotate, then scale, then the transform property.
Animation Composition
For other properties, or when you do need to stack changes on the same property, animation-composition controls how an animation's value combines with the underlying value:
replace(default): the animation value replaces the underlying value.add: the animation value is appended to the underlying value. For transforms, the functions are concatenated.accumulate: values are combined numerically, sotranslateX(10px)plustranslateX(20px)becomestranslateX(30px).
.card {
transform: rotate(-3deg);
animation: lift 400ms ease-out both;
animation-composition: add;
}
@keyframes lift {
to {
transform: translateY(-0.5rem);
}
}
With add, the card keeps its tilt and lifts at the same time. With the default replace, the tilt would vanish during the animation.
Advanced Techniques
Parameterising Keyframes with Custom Properties
Keyframes can reference custom properties, and those properties resolve on the element being animated. That makes one @keyframes rule reusable with different settings:
@keyframes slide-in {
from {
opacity: 0;
translate: var(--slide-x, 0) var(--slide-y, 1rem);
}
}
.from-left {
--slide-x: -2rem;
--slide-y: 0;
}
.from-below {
--slide-y: 2rem;
}
.enter {
animation: slide-in 500ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
<h2 class="enter from-left">Ship faster</h2>
<p class="enter from-below">Build pipelines that stay out of your way.</p>
One keyframes rule, two behaviours, controlled entirely from markup.
Staggered Entrances
Staggering items in a list gives an interface a sense of choreography. Set a per-item index as a custom property and use it to compute the delay:
<ul class="menu">
<li style="--i: 0">Dashboard</li>
<li style="--i: 1">Projects</li>
<li style="--i: 2">Reports</li>
<li style="--i: 3">Settings</li>
</ul>
.menu li {
animation: slide-in 400ms ease-out both;
animation-delay: calc(var(--i) * 60ms);
}
If you'd rather not write the index in HTML, a handful of :nth-child() rules works too, and newer browsers are adding sibling-index() to compute it natively. Support for sibling-index() is still limited, so treat it as an enhancement:
.menu li:nth-child(2) {
--i: 1;
}
.menu li:nth-child(3) {
--i: 2;
}
.menu li:nth-child(4) {
--i: 3;
}
@supports (animation-delay: calc(sibling-index() * 1ms)) {
.menu li {
animation-delay: calc((sibling-index() - 1) * 60ms);
}
}
Keep stagger intervals small, around 40 to 80 milliseconds. Long staggers make users wait for the interface.
Negative Delays
A negative animation-delay starts the animation partway through its cycle. This is invaluable for groups of infinite animations that should look out of sync:
.loader span {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: #818cf8;
animation: pulse 1.2s ease-in-out infinite;
}
.loader span:nth-child(2) {
animation-delay: -0.8s;
}
.loader span:nth-child(3) {
animation-delay: -0.4s;
}
All three dots start moving on the very first frame, each at a different phase. A positive delay would leave the later dots frozen at first.
Animating Custom Properties with @property
Normally, custom properties can't be animated smoothly because the browser doesn't know their type. Register the property with @property, and it becomes interpolable, which enables effects like rotating gradients:
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes rotate-border {
to {
--angle: 360deg;
}
}
.glow-card {
border: 3px solid transparent;
border-radius: 1rem;
background:
linear-gradient(#0f172a, #0f172a) padding-box,
conic-gradient(from var(--angle), #38bdf8, #818cf8, #f472b6, #38bdf8)
border-box;
animation: rotate-border 4s linear infinite;
}
Without @property, --angle would jump from 0deg to 360deg at the midpoint. With it, the gradient border rotates smoothly. @property is supported across all modern browsers.
Controlling Animations on Demand
animation-play-state lets you pause and resume without restarting:
.marquee__track {
animation: scroll-left 30s linear infinite;
}
.marquee:hover .marquee__track,
.marquee:focus-within .marquee__track {
animation-play-state: paused;
}
Pausing on hover and focus is also an accessibility courtesy for content that moves continuously.
To restart an animation from JavaScript, the Web Animations API is cleaner than the old trick of removing and re-adding a class:
const badge = document.querySelector(".badge");
badge.getAnimations().forEach((animation) => {
animation.cancel();
animation.play();
});
getAnimations() returns the CSS animations currently applied to the element, so you can also read their progress or listen for animationend events to chain behaviour.
Performance Best Practices
- Animate
transformandopacitywhenever possible. Browsers can run these on the compositor without recalculating layout. Animatingwidth,height,top, ormargintriggers layout work on every frame. - Use
will-changesparingly. It can help for an element that's about to animate, but applying it to many elements wastes memory. Let the browser decide in most cases. - Stop infinite animations you can't see. An infinite animation on an element that's off-screen or inside a closed panel still costs resources. Pause it or remove the class when it isn't visible.
- Keep UI animations short. Most interface transitions feel right between 150 and 500 milliseconds. Decorative loops can be longer.
Respecting Reduced Motion
Some users experience nausea or dizziness from motion on screen. The prefers-reduced-motion media query lets you tone animations down:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
This global reset makes animations resolve almost instantly and prevents infinite loops, while still firing animationend events that your JavaScript might depend on. For a more considered approach, replace large movements with a simple fade rather than removing motion completely.
Common Pitfalls
- Keyframe name typos fail silently. If an animation does nothing, check that
animation-nameexactly matches the@keyframesname. Names are case-sensitive. - The shorthand resets everything. Declaring
animationafteranimation-delaywipes the delay back to0s. Put longhands after the shorthand. display: nonecancels animations. Hiding an element stops its animation, and showing it again restarts from the beginning.- Inline elements ignore transforms. A
spanneedsdisplay: inline-blockor similar before transforms likescaletake effect.
Conclusion
Keyframe animations start simple: define some keyframes, attach them with the animation shorthand, and you have motion. The real depth comes from the individual properties. Fill modes make delayed entrances look right, per-keyframe easing gives motion weight, and steps() handles sprite-like effects. Individual transform properties and animation-composition let multiple animations coexist, custom properties turn one keyframes rule into a reusable component, and @property makes previously impossible effects smooth.
Build a small library of well-named, parameterised keyframes for your project, keep them on compositor-friendly properties, and always include a reduced-motion path. You'll find that most of the motion you used to reach for a library to build is already sitting in CSS.


