Web Animation Performance: The Complete Guide to 60fps UI Animations
Short Answer: Why most web animations are unnecessarily janky, what the browser
Animation performance is not a secondary concern — it's foundational to the perception of quality. Users don't consciously track frame rates, but the difference between a 60fps animation and a 30fps animation is viscerally felt. A product that feels fast, responsive, and smooth is perceived as higher quality, more reliable, and more professional than an equivalent product with stuttering transitions, regardless of the underlying UX quality.
The bad news: most UI animations, including many built by experienced developers, run slower than they should because they trigger expensive browser operations that could be avoided.
The good news: the rules for performant animation are simple and consistent. This guide explains the browser's rendering pipeline, the rules that follow from it, and how to build animation systems that maintain 60fps even on low-end mobile devices.
How the Browser Renders a Frame
To understand animation performance, you need to understand what the browser does to display each frame. The browser's rendering pipeline has five stages:
1. JavaScript — Your JS runs, mutating the DOM, setting styles, computing positions.
2. Style — The browser resolves which CSS rules apply to which elements (the CSSOM).
3. Layout — The browser calculates the size and position of every element on the page. This is the expensive step.
4. Paint — The browser fills in the pixels for each element within its layout boundaries. Painting is also expensive.
5. Composite — The browser combines separately-painted layers into the final screen image. This is handled by the GPU and is inexpensive.
At 60fps, the browser has 16.67 milliseconds per frame to complete all five stages. If your animation triggers steps 3 (Layout) or 4 (Paint) on every frame, there may not be enough time to complete them, and the browser drops the frame — this is "jank."
The Two Cheap Properties
Only two CSS properties trigger only the Compositing stage — skipping Layout and Paint:
transform(translate, rotate, scale, skew)opacity
Animating anything else — width, height, top, left, margin, padding, font-size, background-color, box-shadow — forces the browser to re-run Layout, Paint, or both on every frame.
This is the most important performance rule for animation:
Animate only
transformandopacity. Use these two properties for every animation that must run at 60fps.
will-change: Creating Composite Layers
The browser automatically promotes elements to their own GPU composite layer when they have a CSS animation on transform or opacity. You can also manually hint the browser to pre-promote an element using will-change:
/* Hint the browser to pre-create a GPU layer for this element */
.animated-modal {
will-change: transform, opacity
};
Promoting to a GPU layer means the element is rendered separately and composited rather than painted into the main layer. Compositing is fast (GPU-accelerated); painting is slow (CPU).
Important caveats:
- Use
will-changesparingly. Every GPU layer consumes VRAM. Promoting too many elements causes memory pressure that itself degrades performance. - Don't set
will-change: transformon every element "just in case" — measure first, optimize where it's actually needed. - Remove
will-changewhen the animation is complete:element.style.willChange = 'auto'
The CSS Animation vs. JavaScript Animation Decision
Both CSS animations and JavaScript animations can be performant if they're animated on the right properties. The choice is primarily about capabilities:
CSS transition and @keyframes:
- Automatically off the main thread for
transformandopacity - Limited easing options (predefined functions + cubic-bezier)
- Cannot be imperatively paused, reversed mid-animation, or made interactive
- Perfect for: hover states, page transitions, simple in/out reveals
JavaScript Web Animation API (WAAPI):
- Full imperative control: pause, reverse, seek to specific time
- Dynamic values: animate to calculated positions based on user input
- Sequence and group animations
- Also runs off main thread for transform/opacity via the compositor
- Perfect for: scroll-driven animations, interactive animations, gesture responses
// Web Animation API — performant and fully controllable
const modal = document.querySelector('.modal');
// Animate in
const openAnimation = modal.animate(
[
{ opacity: 0, transform: 'scale(0.95) translateY(-8px)' },
{ opacity: 1, transform: 'scale(1) translateY(0)' }
],
{
duration: 200,
easing: 'cubic-bezier(0.16, 1, 0.3, 1)', // spring-like ease-out
fill: 'forwards'
}
);
// The animation returns a promise that resolves on completion
await openAnimation.finished;
// Or pause imperatively
openAnimation.pause();
// Or reverse
openAnimation.reverse();
Preventing Layout Thrash
Layout thrash is the most common cause of animation jank outside of animating the wrong properties. It occurs when JavaScript reads a layout-dependent value (like element.getBoundingClientRect()) after writing to the DOM in the same frame.
Each read after a write forces the browser to re-run layout synchronously, in the middle of your JavaScript execution, to calculate the updated layout values. If this happens multiple times per frame, the browser can spend its entire 16ms frame budget on forced synchronous layouts.
// ❌ LAYOUT THRASH — forces layout 3 times per call
function badAnimation(elements) {
elements.forEach(el => {
const height = el.offsetHeight; // READ: forces layout 1
el.style.height = height * 2 + 'px'; // WRITE
const width = el.offsetWidth; // READ: forces layout 2
el.style.width = width * 2 + 'px'; // WRITE
})
};
// ✅ BATCH reads and writes separately
function goodAnimation(elements) {
// Phase 1: ALL reads
const measurements = elements.map(el => ({
height: el.offsetHeight,
width: el.offsetWidth
}));
// Phase 2: ALL writes (layout runs once at end of frame)
elements.forEach((el, i) => {
el.style.transform = `scale(${measurements[i].width * 2 / measurements[i].width})`;
// Better yet: use transform instead of width/height!
})
};
The requestAnimationFrame (rAF) API helps structure this correctly by guaranteeing your callback runs at the beginning of the frame, before the browser's layout phase:
// Read → requestAnimationFrame → Write pattern
function animateWithRAF(element) {
// Read phase (happens before rAF callback)
const startRect = element.getBoundingClientRect();
requestAnimationFrame(() => {
// Write phase (happens at beginning of next frame)
element.style.transform = `translateX(${startRect.width}px)` })
};
Scroll-Driven Animations: The Right Way in 2026
Scroll-driven animations are the most widely implemented and most commonly broken animation type in 2026. The legacy approach using a scroll event listener has deep performance problems:
// ❌ LEGACY: Scroll listener on main thread — causes jank on low-end devices
window.addEventListener('scroll', () => {
const progress = window.scrollY / document.body.scrollHeight;
element.style.opacity = progress.toString();
});
The problem: scroll events fire on the main thread. If the main thread is busy (running JavaScript, rendering a frame), scroll events queue up. On low-end mobile devices, this produces the characteristic "sticky/jumpy" scroll behavior.
The modern solution: CSS Scroll-Driven Animations API (baseline support 2024+):
/* Animate an element as the page scrolls — entirely off main thread */
@keyframes fade-in-from-scroll {
from { opacity: 0; transform: translateY(24px)
};
to { opacity: 1; transform: translateY(0)
};
}
.article-section {
animation: fade-in-from-scroll linear both;
/* Link the animation to the element's scroll position in the viewport */
animation-timeline: view();
/* Start animating when element is 20% into view, end at 50% into view */
animation-range: entry 0% entry 40%
};
This runs entirely on the compositor thread — no JavaScript, no main thread blocking.
For environments requiring broader browser support (notably Firefox, which gained support in mid-2024), a polyfill or the Intersection Observer API provides the next best option:
// Intersection Observer: efficient scroll reveal without jank
const revealObserver = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
// Optional: stop observing after reveal
revealObserver.unobserve(entry.target)
};
});
},
{ threshold: 0.15 }
);
document.querySelectorAll('.reveal-on-scroll').forEach(el => {
revealObserver.observe(el);
});
/* CSS handles the actual animation — runs off main thread */
.reveal-on-scroll {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.5s cubic-bezier(0.16, 1, 0.3, 1)
};
.reveal-on-scroll.is-visible {
opacity: 1;
transform: none
};
/* Accessibility: respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.reveal-on-scroll {
opacity: 1;
transform: none;
transition: none
};
}
Easing Functions: The Physics of Perceived Quality
The easing function is the difference between animation that feels mechanical and animation that feels physical and alive. The physics intuition: real objects have inertia. They don't start immediately at full speed and stop immediately — they accelerate out of rest and decelerate into their target position.
The 5 easing functions you'll use most:
/* 1. Linear — for continuous animations, progress bars, loading spinners */
transition-timing-function: linear;
/* 2. ease-in — for exit animations (object leaving viewport or fading out) */
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
/* 3. ease-out — for entrance animations (modal appearing, notification in) */
transition-timing-function: cubic-bezier(0, 0, 0.2, 1);
/* 4. ease-in-out — for position changes within viewport */
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
/* 5. Spring — overshoots target slightly, creates satisfying physical feel */
/* Not available as CSS keyword — must use cubic-bezier approximation */
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
The duration rule: Most UI animations should be 150–350ms. Below 100ms, transitions feel instantaneous (missing the purpose of the transition). Above 400ms, users feel they're waiting for the UI rather than experiencing a transition. Exception: animations for decorative/ambient purposes (loading states, illustrations) can be longer.
Animation Performance Audit Checklist
Use Chrome DevTools Performance panel to verify these before shipping:
- All runtime animations use only
transformandopacity - No
width,height,top,left,marginanimations in production - Scroll animations use CSS Scroll-Driven API or IntersectionObserver (not scroll event listeners)
- Layout thrash eliminated: reads and writes separated within each animation frame
-
will-changeapplied only where measured performance gain exists - Frame rate confirmed 60fps on mid-range Android device (Moto G Power or equivalent)
-
prefers-reduced-motionmedia query disables all non-essential animations - Looping animations use CSS
animation(not setInterval) - All animations have defined completion states (fill: forwards or explicit reset)
- Chrome Performance panel shows no "Layout" or "Paint" tasks during animation playback
About the Editorial Team This analysis was conducted by our independent research desk. We utilize verified market data and specialized methodology to provide objective, expert insights. Our strict editorial policy ensures no undue influence from sponsors or external parties.