A CSS @keyframes rule is a timeline with named stops. You describe what an element looks like at a few moments, the browser interpolates every frame in between, and one property, animation-name, wires that timeline to the element. That is the whole model.
The model is simple. The details cost afternoons.
CSS animation has been defined by the @keyframes at-rule since the CSS Animations Level 1 specification, and the vendor prefixes are long dead. Plain @keyframes ships in every current browser. What follows is the set of behaviors that a five-line demo never shows you, the ones that decide whether your animation works or quietly does the wrong thing.
from and to vs percentages
from and to are aliases. from is the 0% keyframe, to is the 100% keyframe, and the browser treats them as the exact same thing you would get by typing the numbers. Nothing is lost either way.
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* identical result */
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}
Reach for from/to when there are exactly two stops and the animation reads like a sentence: start here, end there. The moment you need a third stop, switch to percentages, because there is no keyword for the middle. There never was a middle. If you want the element to do something at the halfway point, you write 50%, and once one stop is a number the others usually read better as numbers too.
You can mix them, though. from, 50%, to in the same block is legal and sometimes clearer than three bare percentages. Do whatever makes the timeline easiest to read six months from now.
Sharing one keyframe rule across several stops
Stop writing the same declaration twice. A single keyframe stop accepts a comma-separated list of positions, so if the element holds the same values at more than one moment, you say it once.
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.08); }
}
The 0% and 100% stops share one rule. The element scales up to 1.08 at the midpoint and settles back to where it started, which is what makes a pulse loop cleanly with no jump between the last frame and the first. Group any stops that hold identical values. It reads better, and it is one fewer place to forget an edit when you tweak the scale later.
What happens to properties you omit
What does the browser do with a property you set in one keyframe but leave out of another? It does not freeze and it does not guess randomly. It reaches for the element’s computed base value, the value the element would have with no animation running at all, and fills the gap with that.
Here is the case that trips people. You write a single middle keyframe and nothing else:
.chip {
transform: translateX(0);
}
@keyframes nudge {
50% { transform: translateX(12px); }
}
There is no 0% and no 100% in that rule. The engine synthesizes both for you, and it fills them with the base value translateX(0). So the chip slides out to 12px at the halfway mark and slides home, a full there-and-back motion from a single line. Those are implicit keyframes, and they are doing real work you never typed.
Now the gotcha. Change the base rule and the implicit stops move with it. If .chip had transform: translateX(50px) as its normal style, the nudge would start and end at 50px, not 0, because the synthesized 0% and 100% copy the base value. The keyframe you wrote did not move. The floor under it did.
One related rule lives here too. A declaration marked !important inside a keyframe is thrown away. The animation engine ignores it on purpose, as the spec requires, so this does nothing:
@keyframes bad {
to { opacity: 0 !important; } /* !important is ignored in keyframes */
}
If a value inside a keyframe refuses to apply, that !important is not the fix and never was. Look at specificity in your normal rules, or at another animation overwriting the same property. The keyframe is behaving exactly as designed.
Running multiple animations on one element
One element, two animations, no wrapper div. Every animation longhand takes a comma-separated list, and the position in each list lines up with a name in animation-name.
.badge {
animation-name: slide-in, glow;
animation-duration: 400ms, 2s;
animation-timing-function: ease-out, ease-in-out;
animation-iteration-count: 1, infinite;
animation-delay: 0s, 400ms;
}
slide-in runs once and gets out of the way. glow starts the instant the slide finishes, thanks to its 400ms delay, then loops forever. Two independent animations, one element, and they coexist because they touch different properties.
The trap is mismatched list lengths. Give two names but one duration and the single duration applies to both animations. Give three durations for two names and the extra one is dropped. The lists cycle to fill any shortfall, which is convenient right up until it silently hands an animation the wrong timing and you cannot see why. Keep the columns even. Line them up in your editor like the block above and a mismatch becomes obvious.
There is a limit worth knowing. If two animations in the same list both write transform, they do not add up. The last one in the list wins for that frame. You cannot translate with one animation and rotate with another on the same element and expect both to show. For that, compose both moves inside a single keyframe (transform: translateX(10px) rotate(8deg)), or nest a second element and animate that.
Naming and scoping keyframes
I once lost twenty minutes on a spinner that refused to spin. Two components, two @keyframes spin blocks, different files. The second one loaded last and quietly replaced the first, so half my UI span and half sat dead still.
Keyframe names live in one global namespace for the whole document. There is no per-component scope, no module boundary. Two rules with the same name do not merge and they do not coexist. The later one in cascade order wins outright, and the earlier one is gone. Print that sentence out. Tape it near the monitor.
The fix is boring and it works: prefix your names. card-spin, toast-slide, hero-blob. Treat every keyframe name like a global variable, because that is exactly what it is.
A few naming rules the parser enforces, so you do not fight them by accident:
- Names are case-sensitive.
Spinandspinare two different animations, and mixing them up gives you the silent-no-animation bug. - The CSS-wide keywords are off-limits as names:
none,initial,inherit,unset, andrevert.noneis the common accident, because it reads like “no animation” and animation-name treats it that way. - You can define
@keyframesinside a@mediaor@supportsblock, which is how one name can mean different things at different breakpoints. Handy on occasion, baffling if you forget you did it.
Keyframes are half the machine. The other half is the animation-* properties that decide how long the timeline runs, how many times, and in which direction, all covered in the parent CSS animation guide. Pick the wrong timing function and even flawless keyframes feel cheap and mechanical. Animate the wrong property inside them and the whole thing stutters, which is a performance problem, not a keyframe one. Get the timeline, the easing, and the property right together, and you have something you can actually ship.