CSS Animations & Keyframes
@keyframes and the animation property in full — building genuinely custom motion beyond simple hover transitions.
Quick answer
@keyframes defines the steps of an animation and the animation property runs it, with a duration, timing, repeat count, direction and fill mode. Unlike transitions, animations need no trigger and can have many steps and loop forever.
- Use percentages (0%, 50%, 100%) or from and to for keyframes.
- animation-fill-mode: forwards keeps the final frame after the animation ends.
- Respect prefers-reduced-motion for users who turn motion off.
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.loader { animation: pulse 1.2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.loader { animation: none; }
}When Two States Aren't Enough
The Transitions module covered smooth movement between exactly two states — a resting state and a triggered state (like :hover). A CSS animation, defined with @keyframes, removes that two-state limit entirely: it can define any number of intermediate steps, run automatically without needing a trigger like hover or a class toggle, and loop indefinitely.
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 1s linear infinite;
}
/* No hover, no class toggle, no trigger of any kind — this runs
continuously the instant the element exists in the DOM */Defining the Steps of the Motion
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.7; }
100% { transform: scale(1); opacity: 1; }
}from/to are exactly equivalent to 0%/100% — the percentage form is required the moment you need any intermediate step beyond just a start and end point, as the pulse example above shows.
Every Sub-Property, Explained
.element {
animation-name: pulse; /* which @keyframes block to use */
animation-duration: 2s; /* one full cycle's length */
animation-timing-function: ease-in-out; /* the pacing curve within one cycle */
animation-iteration-count: infinite; /* or a specific number like 3 */
animation-direction: alternate; /* normal / reverse / alternate / alternate-reverse */
animation-delay: 0.5s; /* wait before starting */
animation-fill-mode: forwards; /* what state to hold before/after running */
}
/* The shorthand — same six values, one line */
.element {
animation: pulse 2s ease-in-out infinite alternate 0.5s forwards;
}animation-direction: alternate is what makes the pulse example above breathe in and out smoothly rather than snapping back to the start at the end of every cycle — it reverses the keyframe playback on every other iteration instead of always playing forward from 0% to 100%.
The Property That Confuses Almost Everyone Once
By default, an element snaps back to its original, pre-animation styles the instant the animation finishes (or before it starts, during any animation-delay) — animation-fill-mode controls whether the animation's first or last keyframe state is instead held onto outside its active running time.
@keyframes slide-in {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
}
.card {
animation: slide-in 0.5s ease-out;
/* After 0.5s, the element reverts to whatever transform it had
BEFORE the animation — often back to no transform at all,
undoing the slide-in visually the instant it "finishes" */
}.card {
animation: slide-in 0.5s ease-out forwards;
/* The element stays at transform: translateX(0) permanently
after the animation completes, exactly as intended */
}forwards, the browser reverts every animated property back to its pre-animation value the moment the animation ends.A Clear Decision, Not a Style Preference
.button {
transition: background-color 0.2s;
}
.button:hover {
background-color: #4285f4;
}@keyframes attention-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-6px); }
75% { transform: translateX(6px); }
}
.error-field {
animation: attention-shake 0.4s ease-in-out;
}The decision is genuinely mechanical: if the motion is a simple change between exactly two states and something (hover, a class change) triggers it, a transition is simpler and sufficient. The moment you need more than two states, need the motion to loop, or need it to run automatically without an external trigger, only @keyframes can do it.
The Same Cheap-vs-Expensive Rule Still Applies
Everything from the Transitions module about which properties animate cheaply (transform, opacity — GPU-accelerated, no layout recalculation) versus expensively (width, height, top/left — trigger layout reflow on every single frame) applies identically inside @keyframes. An animation looping indefinitely on an expensive property is a much more serious, sustained performance cost than a one-off transition, since it runs continuously rather than just once.
@keyframes slide-loop {
from { left: 0; }
to { left: 100px; }
}
/* Every single frame recalculates layout for this element AND
potentially its siblings — running "infinite" makes this cost ongoing */@keyframes slide-loop {
from { transform: translateX(0); }
to { transform: translateX(100px); }
}
/* GPU-composited, no layout recalculation on any frame */A "Broken" Toast Notification, at a Nashville Fintech Startup
A "payment successful" toast notification is built with a slide-in-and-fade-in animation. QA reports that the toast appears to flash on screen instantly for a single frame, then vanish, before its animation has even visually started.
@keyframes toast-in {
from { transform: translateY(-20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.toast {
animation: toast-in 0.3s ease-out;
}What was actually happening
Without animation-fill-mode: forwards, the browser reverted the toast's transform and opacity back to their pre-animation CSS values the instant the 0.3-second animation completed — and since the element's base (non-animated) styles had opacity: 1 and no transform at all, the toast briefly animated in, then immediately snapped to a DIFFERENT, also-fully-visible state, reading as a confusing flicker rather than a clean, held final position. Adding forwards to the animation shorthand fixed it entirely — the engineer's own note afterward: "the animation was working correctly the whole time. It just had nowhere to land."
Four Misconceptions About CSS Animations
5 Interview Questions — With Complete Answers
Animation Mistakes Beginners Make Constantly
Issues You Will Hit With CSS Animations — And Exactly Why
🎯 Key Takeaways
- ✓@keyframes defines any number of steps and can run automatically without a trigger, looping indefinitely — capabilities a two-state transition simply does not have.
- ✓The animation shorthand combines name, duration, timing-function, iteration-count, direction, delay, and fill-mode in one line.
- ✓animation-fill-mode: forwards is required to hold an animation's final state — without it, the element reverts to its pre-animation styles the instant the animation completes, a genuinely common source of flickering UI bugs.
- ✓animation-direction: alternate reverses keyframe playback on alternating cycles, producing smooth back-and-forth motion instead of an abrupt reset.
- ✓Choose a plain transition for a simple, triggered two-state change; reach for @keyframes only once you need more steps, looping, or automatic playback.
- ✓The cheap-vs-expensive property distinction from Transitions applies with MORE consequence to animations, since a looping animation pays its cost on every frame for as long as it runs.
What comes next
Lesson 33 covers CSS transforms in 2D and 3D — translate, rotate, scale, skew, and building real perspective-based depth.
Lesson 33 → CSS Transforms (2D and 3D)Quick check
An element snaps back to its start state when its animation ends. Which fixes it?
HELP US IMPROVE
How was this lesson?
Your note is shared with the Chaduvuko team for review. Please don’t include personal or sensitive information.
Spotted a mistake or something unclear? Suggest a fix on GitHub or email us. Select the text first and it is quoted for you.
Discussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.
