Animation is not decoration. Good animation explains: the button "pressed", the panel "slid in", the data is "loading". Bad animation — jank and jumps — is a sign that the browser cannot fit within the 16-millisecond frame budget.
Two paths: CSS and JavaScript
CSS animations are the first choice for most tasks. The browser optimises them on its own.
/* transition — smooth change between states */
.button {
transition: background-color 0.2s ease;
}
.button:hover { background-color: #1a3d34; }
/* animation — a sequence or infinite loop */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.skeleton { animation: pulse 1.5s ease-in-out infinite; }
transition — when you need to smoothly move between two states on an event. animation + @keyframes — for multi-step sequences or infinite loops.
JS animations are needed when the logic depends on data — cursor position, physics, dynamic trajectory. The right tool is requestAnimationFrame (rAF), not setTimeout.
function animate(timestamp) {
element.style.transform = `translateX(${Math.sin(timestamp / 500) * 100}px)`;
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
requestAnimationFrame synchronises with the browser's render loop — it is called before each frame, roughly 60 times per second. setTimeout(fn, 16) gives no such guarantee: it can fall between frames or be delayed by a busy thread. For more on how the event loop manages browser tasks, see Event Loop and asynchronous code.
The key performance rule: transform and opacity
The browser paints the page in layers. When you animate transform or opacity, the browser moves a finished layer on the GPU — without recalculating geometry, without repainting. This is a composite-only operation: fast and cheap.
When you animate top, left, or width — the browser runs the full cycle: recalculates geometry (reflow), repaints, composites layers. On every frame. That is the source of jank.
/* bad: reflow on every frame */
.card { transition: top 0.3s; }
.card:hover { top: -4px; }
/* good: composite-only, no reflow */
.card { transition: transform 0.3s; }
.card:hover { transform: translateY(-4px); }
Why layout is expensive — in the article Critical rendering path.
will-change: use with care
will-change is a hint to the browser to create a GPU layer ahead of time, before the animation starts:
.animated-card { will-change: transform; }
It eliminates the small jerk at the start. But it should not be applied to everything: every layer consumes GPU memory — if there are hundreds of layers, the browser starts to slow down. Apply only to elements that are actually animated, and remove will-change after one-off animations via JavaScript.
60fps and frame budget
The display refreshes 60 times per second — a new frame every ~16.7 milliseconds. If JavaScript code takes 20 milliseconds — a frame is dropped and the animation stutters. That is exactly why heavy work is not done synchronously during animation — it is offloaded to a Web Worker or broken into chunks with requestAnimationFrame.
prefers-reduced-motion: accessibility
Some users suffer from vestibular disorders — intense animations cause them discomfort. macOS, Windows, and iOS allow enabling a "reduce motion" mode. CSS reads this flag:
@media (prefers-reduced-motion: reduce) {
.hero-animation { animation: none; }
}
Or globally — disable all animations with animation-duration: 0.01ms and transition-duration: 0.01ms for *. For more on accessible interfaces, see Accessibility (a11y).
Common patterns
Appearing and disappearing
display: none cannot be animated — the element disappears instantly. Use opacity + visibility:
.dropdown {
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s;
}
.dropdown.open {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
visibility: hidden removes the element from the interaction flow but allows animating via transition — unlike display: none.
Loading skeletons
A sliding gradient via transform: translateX — composite-only, does not burden the CPU:
@keyframes shimmer {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
.skeleton-line::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
animation: shimmer 1.5s ease-in-out infinite;
}
Micro-interactions
Small interface responses are done with transition on :active or a CSS class change:
.like-button { transition: transform 0.15s ease; }
.like-button:active { transform: scale(0.9); }
.like-button.liked { transform: scale(1.15); }
Animations in React
The simplest approach — CSS classes based on component state:
<div className={`notification ${visible ? 'notification--visible' : ''}`}>
Saved
</div>
.notification {
opacity: 0;
transform: translateY(8px);
transition: opacity 0.25s ease, transform 0.25s ease;
}
.notification--visible { opacity: 1; transform: translateY(0); }
This approach covers most cases. A library (such as Framer Motion) is needed for route transition animations or complex chains — it uses transform and opacity under the hood and supports prefers-reduced-motion out of the box.
In short
- CSS
transition— for event-driven state changes;animation+@keyframes— for sequences and infinite loops. requestAnimationFrame— the only correct tool for JS animations;setTimeoutis not synchronised with the render frame.- Animate
transformandopacity— composite-only, no reflow. Do not animatetop,left,width— that is layout on every frame. will-change: transformcreates a GPU layer ahead of time; it should not be applied to everything — every layer consumes GPU memory.- Frame budget ~16ms: heavy computation during animation causes dropped frames and jank.
prefers-reduced-motion: reduce— a CSS media query to disable animations for users with the corresponding system setting.display: nonecannot be animated — useopacity+visibilityfor smooth appearance and disappearance.- In React: CSS classes based on state cover most cases; a library is only needed for complex chains and route transitions.
What to read next
- Critical rendering path — why layout is expensive and what composite means.
- Event Loop and asynchronous code — how requestAnimationFrame fits into the browser cycle.
- Rendering performance — optimising React components and code-splitting.
- Accessibility (a11y) — prefers-reduced-motion and other aspects of accessible interfaces.