CSS Animations: Transitions, Keyframes, and Not Janking the Browser
Practical guide to CSS transitions, @keyframes animations, and transforms. How to build hover effects, loading spinners, and page transitions that actually run at 60fps.
CSS has two animation systems and they solve different problems. Transitions handle the simple case: "smoothly change from A to B when something happens." Keyframe animations handle everything else: "play this multi-step animation on a loop, with pauses, reversals, and custom timing." Knowing which to reach for saves you a lot of overengineering.
Transitions: The Easy One
A transition watches a CSS property and smoothly interpolates when that property changes (usually on hover, focus, or a class toggle).
.button {
background: #2563eb;
transform: scale(1);
transition: background 200ms ease, transform 150ms ease;
}
.button:hover {
background: #1d4ed8;
transform: scale(1.05);
}
The transition shorthand takes four values:
- property -- which CSS property to animate (
allworks but is lazy; be specific) - duration -- how long the transition takes (
200ms,0.3s) - timing-function -- the acceleration curve (
ease,ease-in-out,linear, orcubic-bezier()) - delay -- wait before starting (
0sby default)
A few things to know:
- Not every CSS property can be transitioned.
display,visibility(sort of), and anything that isn't a continuous value won't work.color,opacity,transform,background,box-shadow-- those all transition smoothly. - Put the
transitionon the base state, not the:hoverstate. Otherwise the transition only plays on hover-in, not hover-out. transition: all 300ms easeis tempting but it transitions everything that changes, including properties you didn't intend. Spell out the properties.
@keyframes: The Powerful One
When you need more than "go from A to B" -- multiple steps, looping, or animations that play on load -- you need @keyframes.
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.loader {
width: 40px;
height: 40px;
border: 3px solid #e5e7eb;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
The animation shorthand:
animation: name duration timing-function delay iteration-count direction fill-mode;
The properties you'll actually set:
animation-name-- references the@keyframesblockanimation-duration-- how long one cycle takesanimation-timing-function--linearfor constant speed,ease-in-outfor natural motionanimation-iteration-count-- a number, orinfiniteanimation-direction--normal,reverse,alternate(ping-pong back and forth)animation-fill-mode--forwardskeeps the final state after the animation ends. Without it, the element snaps back.
@keyframes pulse {
0% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(0.95); }
100% { opacity: 1; transform: scale(1); }
}
.skeleton {
animation: pulse 1.5s ease-in-out infinite;
}
Transform: The GPU-Accelerated Workhorse
transform doesn't animate anything by itself -- it just changes an element's visual representation. But it's the backbone of performant animations because the browser can offload transforms to the GPU.
.card:hover {
transform: translateY(-4px) scale(1.02);
}
The transforms you'll use constantly:
translate(x, y)-- moves the element.translateX(),translateY(),translateZ()for individual axesscale(factor)-- resizes.scale(1.1)is 10% biggerrotate(angle)-- spins.rotate(45deg),rotate(0.5turn)
transform: translateY(-8px) rotate(2deg) scale(1.05). They apply right to left, which matters if you're combining rotation with translation.
Performance: The 60fps Rule
The browser renders at 60 frames per second (ideally). That's ~16.6ms per frame. If your animation triggers layout recalculation or repainting, it'll blow past that budget and stutter.
The golden rule: only animatetransform and opacity. These two properties can be composited on the GPU without triggering layout or paint. Everything else -- width, height, top, left, margin, padding, border, box-shadow -- forces the browser to recalculate layout, repaint pixels, or both.
Bad (triggers layout every frame):
.slide-in {
transition: left 300ms ease;
left: -100%;
}
.slide-in.active {
left: 0;
}
Good (GPU-composited):
.slide-in {
transition: transform 300ms ease;
transform: translateX(-100%);
}
.slide-in.active {
transform: translateX(0);
}
Same visual result, vastly different performance on mobile devices.
will-change
will-change tells the browser to prepare a GPU layer for an element before the animation starts:
.card {
will-change: transform;
transition: transform 200ms ease;
}
Use it sparingly. Every will-change creates a new compositor layer that consumes GPU memory. Slapping will-change: transform on fifty cards is worse than the jank you're trying to avoid. Apply it to the few elements that actually need buttery-smooth animation -- hero sections, modals, carousels. Remove it when the animation is done if possible (via JavaScript).
Practical Examples
Fade-in on scroll (CSS part)
.fade-up {
opacity: 0;
transform: translateY(20px);
transition: opacity 500ms ease, transform 500ms ease;
}
.fade-up.visible {
opacity: 1;
transform: translateY(0);
}
Toggle the .visible class via IntersectionObserver in JS. Simple, performant, no animation library needed.
Page transition overlay
@keyframes slideOut {
from { transform: translateX(0); }
to { transform: translateX(100%); }
}
.page-transition {
position: fixed;
inset: 0;
background: #1e293b;
animation: slideOut 400ms ease-in forwards;
}
Staggered list items
.list-item {
opacity: 0;
transform: translateY(12px);
animation: fadeIn 300ms ease forwards;
}
.list-item:nth-child(1) { animation-delay: 0ms; }
.list-item:nth-child(2) { animation-delay: 80ms; }
.list-item:nth-child(3) { animation-delay: 160ms; }
.list-item:nth-child(4) { animation-delay: 240ms; }
@keyframes fadeIn {
to { opacity: 1; transform: translateY(0); }
}
For more than a few items, generate the delays with a CSS custom property and calc(), or use nth-child() formulas in a preprocessor.
Respecting User Preferences
Some people get motion sickness from animations. The prefers-reduced-motion media query lets you tone things down:
@media (prefers-reduced-motion: reduce) {
, ::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
This doesn't remove animations entirely -- it just makes them instant, so state changes still happen but nothing moves.
Build Something
Reading about easing curves and keyframe percentages only gets you so far. The real learning happens when you try to build a loading spinner and it wobbles wrong, or a hover effect feels sluggish until you switch from margin-top to translateY. CodeUp has CSS challenges where you can experiment with animations in the browser and see the results immediately. Start with hover effects, then build a spinner, then try a full page transition.