Understanding CSS animation
Keyframes, timing, and what runs on the GPU.
The four pieces of a CSS animation, the two properties that won't drop frames, and the difference between transitions and animations.
Transition vs animation.
Transitions interpolate between a starting and ending state when a property changes — fade in on hover, slide a drawer open. Animations run a defined sequence regardless of state change — a loading spinner, an attention-pulse, a multi-stage choreography. Transitions are simpler (one duration, one easing, implicit state changes); animations are richer (named keyframes, multiple stops, iteration count, direction).
@keyframes — the choreography.
A keyframe rule names a sequence and lists property values at percentage checkpoints. @keyframes pulse { 0% { opacity: 1 } 50% { opacity: 0.5 } 100% { opacity: 1 } }. Then apply it: animation: pulse 2s ease-in-out infinite. Five values: name, duration, timing-function, iteration-count, optionally direction/fill-mode/delay. The same animation can run on multiple elements at different speeds; the keyframes are reusable.
Two properties don't drop frames.
The browser uses two paths to animate. Compositor path: transform (translate, rotate, scale) and opacity — both can be handled on the GPU without re-laying-out the page. Main thread path: everything else (width, left, color, padding, ...) — each frame triggers layout and paint. For 60fps animations, animate transform and opacity; for one-off layout changes that aren't on the hot path, anything works. The "60fps without thinking" rule: stick to transform and opacity.
Easing curves.
The timing function controls how the animation feels. linear: constant velocity (mechanical, often wrong). ease-in: starts slow, ends fast (acceleration). ease-out: starts fast, ends slow (deceleration — usually feels natural for UI). ease-in-out: slow at both ends (good for pulsing). cubic-bezier(0.25, 0.1, 0.25, 1): custom curve. The default "ease" is roughly cubic-bezier(0.25, 0.1, 0.25, 1). Test with the actual content; what works for one motion doesn't for another.
A worked spinner.
A 360° rotating spinner. @keyframes spin { to { transform: rotate(360deg) } } plus .spinner { animation: spin 1s linear infinite }. Three properties matter: transform: rotate (GPU-accelerated, won't drop frames), linear (constant rotation, no easing artifact), and infinite (loops until removed). Five lines of CSS, no JavaScript, renders correctly on every browser since 2012.
Cheap, GPU-friendly spinner
transform rotate ; linear ; infinite
The combination that doesn't trigger layout.
@keyframes spin → animation: spin 1s linear infinite
= 60fps on any browser
prefers-reduced-motion.
Some users get nauseous from animation (vestibular disorders, motion sensitivity). Operating systems expose this as a setting; CSS picks it up via @media (prefers-reduced-motion: reduce). Inside that block, override your animations to be subtle or static. Big motion effects (full-screen slides, parallax scroll, looping background animations) are the priority cases. Spinners and tiny fades are fine to keep.
When to reach for JavaScript.
CSS handles "play this defined sequence". For animation that needs to respond to input mid-flight (drag handles, scroll-driven), for complex sequences with choreographed timing, or for physics-driven motion (spring animations), use a library like Framer Motion, GSAP, or Motion One. They run the same compositor path under the hood; you just trade declarative for imperative.