CSS @keyframes: The Complete Guide (Including the Parts Docs Skip)
Master CSS @keyframes from syntax basics to the timing tricks MDN glosses over. Real examples, gotchas, and patterns that actually hold up in production.
What @keyframes Actually Does (And Doesn't Do)
You've read the MDN page. You know @keyframes defines animation states. But here's what the docs don't say upfront: keyframes don't animate anything on their own. They're a recipe. The animation property on a real element is what cooks it.
The rule itself is dead simple. You give it a name, drop in percentage stops or the from/to shortcuts, and declare what CSS properties change at each stop. The browser figures out the values in between. That interpolation is where things get interesting — and occasionally annoying.
Quick aside: not every CSS property is animatable. You can animate opacity, transform, color, width. You can't animate display or font-family. Trying to animate display: none to display: block is a classic beginner trap — it just flips instantly at the 50% mark, no transition involved.
One more thing — keyframe names are global to the stylesheet scope. Name two @keyframes fade blocks and the second one silently wins. In large codebases this bites you harder than you'd expect, especially when you're pulling in third-party CSS.
The Syntax, Written Plainly
Here's a complete, working example you can drop straight into a project. It fades an element in, nudges it up 12px, and fades back out — a pattern you'll use constantly for toast notifications and tooltips.
@keyframes popIn {
0% {
opacity: 0;
transform: translateY(12px);
}
60% {
opacity: 1;
transform: translateY(-2px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.toast {
animation: popIn 300ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}The forwards fill mode there is doing real work. Without it, your element snaps back to opacity: 0 the instant the animation ends. That forwards keyword tells the browser to hold the final keyframe state. You'd be surprised how many people ship animations that flicker because they forgot this.
Worth noting: you can stack multiple animations on one element with a comma-separated list. animation: popIn 300ms forwards, pulse 1s 300ms infinite. The second animation starts at 300ms (that's the delay), kicks in right as the first one finishes. Handy for loading states.
Timing Functions: The Part That Actually Changes Everything
Timing functions are where most tutorials drop the ball. ease, linear, ease-in-out — fine. But the real control comes from cubic-bezier() and the newer linear() function that landed in all major browsers in 2023.
A cubic bezier takes four numbers defining two control points on a curve. cubic-bezier(0.34, 1.56, 0.64, 1) — notice the second value exceeds 1. That's an overshoot. The element physically goes past its target before settling back. Used carefully, it's the difference between an animation that feels mechanical and one that feels alive.
Honestly, the easiest way to build these is with a visual tool rather than guessing at floats. Once you have a curve you like, you can bake it into a CSS custom property and reuse it everywhere: --spring: cubic-bezier(0.34, 1.56, 0.64, 1). Clean and consistent across your whole system.
In practice, linear() is the more interesting one for 2026. It lets you define an easing curve as a series of output values — basically keyframes inside your timing function. You can fake spring physics, bounce, or any arbitrary motion path without JavaScript. The browser support landed in Chrome 113, Firefox 112, and Safari 17, so you're safe to ship it.
/* Bounce easing with linear() */
.bounce {
animation: slideIn 600ms
linear(0, 0.009, 0.035 2.1%, 0.141, 0.281 6.7%, 0.723 12.9%, 0.938 16.7%,
1.017 19.5%, 1.041, 1.042, 1.038 22.4%, 1.001 27%, 1) forwards;
}animation-fill-mode, animation-direction, and the Flags Nobody Reads
The animation shorthand packs in eight properties. Most developers use three or four and wonder why things look off. Let's go through the ones that actually matter beyond the basics.
animation-fill-mode has four values: none, forwards, backwards, both. You already know forwards. backwards is useful when you have a delay — it applies the first keyframe during the delay period so the element doesn't snap into position when the animation starts. both combines them. Use both by default on anything with a delay.
animation-direction: alternate is underused. Instead of resetting to the start on each iteration, the animation plays forward, then plays in reverse on the next cycle. Perfect for breathing effects, pulsing loaders, or any loop that shouldn't have a jarring jump. Pair it with animation-iteration-count: infinite and you're done.
animation-play-state: paused is how you pause via CSS. Toggle it from JavaScript — el.style.animationPlayState = 'paused' — and the animation freezes exactly where it is. Resume with 'running'. This is vastly cleaner than mucking around with animation-delay offsets to fake a pause.
One more thing — will-change: transform and will-change: opacity tell the browser to promote the element to its own compositor layer before animation begins. Use it sparingly, only on elements you *know* will animate. Slapping will-change on everything is a memory leak waiting to happen.
Keyframe Gotchas That Will Waste Your Afternoon
Why isn't your animation running? Four likely culprits. First: you're animating a property the browser can't interpolate (see display above). Second: the element has animation-duration: 0s somewhere upstream. Third: a parent has overflow: hidden and you're animating a transform that moves outside the box — it won't clip the animation, but it can visually confuse you. Fourth: the @keyframes block has a typo in the name.
Animating width and height is almost always a mistake. They trigger layout, which is expensive, and they look janky on low-end hardware. Animate transform: scaleX() or transform: scaleY() instead — same visual result, GPU-accelerated, no layout reflow. This matters more than you'd think on Android devices from 2021 and earlier.
Look, the animation-timing-function inside a keyframe block overrides the animation-level timing function *for that segment only*. So you can have a fast ease-out from 0–60% and a slow ease-in from 60–100% by declaring animation-timing-function inside the 60% stop. The docs mention this, but it's buried deep enough that most devs never discover it.
If you're building component libraries and want pre-built, well-tested animations without reinventing everything, browse the components at Empire UI — there are motion-ready elements that respect prefers-reduced-motion out of the box, which is the next thing you should care about.
prefers-reduced-motion: Ship It or Someone Gets Sick
This isn't optional. Some users have vestibular disorders where parallax effects and bouncy animations cause genuine nausea and disorientation. The prefers-reduced-motion media query has been in Chrome since version 74, and there's no excuse for ignoring it in 2026.
The cleanest pattern is a global reset at the top of your stylesheet, then add motion back intentionally:
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}That 0.01ms isn't zero — some JavaScript listens for animationend events to trigger follow-up logic. If you kill the duration entirely, those events never fire and you get broken UI. Setting a near-zero value fires the event immediately without any visible motion.
In practice, the better long-term approach is wrapping your keyframe calls in a custom property toggle. Define --motion: ; (empty, animation enabled) and --motion-disabled: initial by default, then swap them under the media query. It's more verbose initially but gives you precise per-component control rather than nuking everything with a wildcard selector. The glassmorphism components on Empire UI use a similar pattern — worth studying if you're building a design system.
Putting It All Together: A Production-Ready Pattern
Here's what a production keyframe setup looks like when you've internalized all of the above. Custom properties for timing curves, both fill mode, prefers-reduced-motion respected, no layout-triggering properties.
:root {
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
--duration-fast: 200ms;
--duration-base: 350ms;
}
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card-enter {
animation: fadeSlideIn var(--duration-base) var(--ease-spring) both;
}
@media (prefers-reduced-motion: reduce) {
.card-enter {
animation: none;
opacity: 1;
}
}That's it. No surprises, no snap-backs, no jank on reduced-motion devices. If you're building on top of a component framework, you can also check out tailwind-css-animations for how to wire these keyframes into Tailwind's animate-* utilities, which keeps your class-based markup clean.
For tools that help you experiment visually before committing to code, the gradient generator and box shadow generator are solid starting points — and they give you the actual CSS output, so there's no gap between what you see and what ships.
FAQ
Yes — comma-separate them in the animation property: animation: fadeIn 300ms forwards, pulse 1s 300ms infinite. Each animation runs independently with its own duration, delay, and timing function.
You're missing animation-fill-mode: forwards (or both if you have a delay). Without it the browser discards the final keyframe state the moment the animation completes.
They're identical — from is an alias for 0% and to for 100%. Use whichever reads more clearly; most style guides prefer the percentage syntax because it's consistent when you add intermediate stops.
It triggers layout recalculation on every frame, which is expensive and visually janky on slower hardware. Animate transform: scaleX() or transform: scaleY() instead — GPU-accelerated and visually equivalent.