Eliminating Layout Jank: Achieving 60 FPS Framer Motion Animations & Zero CLS on Mobile
How to animate React applications without triggering layout reflows: GPU compositing layers, CSS contain rules, and zero layout shift skeletons.
By Uttam Thapa · · Performance
⚡ Executive Summary (TL;DR)
Every smooth animation on the web comes down to one rule: stay on the compositor. Animating
height, top or margin drags layout into every frame and
drops you to a slideshow on a mid-range phone. Animate only transform and opacity, reserve space
with aspect-ratio placeholders, and both 60 fps and a zero CLS score follow from the same discipline.
Figure 1: Which stage your animated property triggers decides whether the frame fits in 16.6 ms.
Three Stages, Wildly Different Prices
Whether an animation is smooth is decided by which part of the rendering pipeline the animated property invalidates. There are three stages and they are not
close in cost.
Stage 1 — avoid
Layout (reflow)
Geometry is recalculated, potentially across the whole subtree. Triggered by width, height, top, margin, padding.
Stage 2 — tolerable
Paint
Pixels are redrawn into layers. Triggered by background-color, box-shadow, border-radius.
Stage 3 — target
Composite
The GPU moves layers that are already drawn. Only transform and opacity get here.
The stages cascade: triggering layout forces paint and composite behind it, so an animated height pays all three costs on every frame. An animated
transform pays one — and pays it on the GPU, off the main thread entirely, which is why it keeps running smoothly even while JavaScript is busy.
The Substitution Table
Almost every layout-triggering animation has a compositor equivalent. Learning the swaps is most of the work.
| Instead of |
Use |
Note |
top / left | transform: translate() | Identical visual result, no layout |
width / height | transform: scale() | Scales children too — often what you want |
height: auto reveal | scaleY() or grid-template-rows | Reserve the final space first |
display: none toggle | opacity plus visibility | display is not animatable at all |
box-shadow on hover | Fade a pseudo-element's opacity | Shadow blur repaints on every frame |
A Compositor-Safe Framer Motion Component
// MotionCard.tsx
import { motion } from 'framer-motion';
export const MotionCard = ({ children }) => (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
{children}
</motion.div>
);
Framer Motion's y compiles to transform: translateY(), not top — which is why it is safe where the equivalent CSS
shorthand would not be. The 15-pixel offset is deliberate: large entrance offsets look dramatic in isolation and read as instability in a list of twenty cards.
🚨 will-change is not a free speed-up
Every will-change promotes the element to its own compositor layer, which costs GPU memory. Applied to a list of items it can exhaust that memory
and make the page slower than doing nothing. Add it immediately before an animation starts and remove it after — or simply leave it off, since modern
browsers promote transformed elements on their own.
The Same Rule Produces Zero CLS
Cumulative Layout Shift measures unexpected movement of content that is already visible. An entrance animation that changes an element's height is, by
definition, moving everything below it — so a layout-animating interface fails CLS by construction, not by accident.
Two rules keep it at zero. Reserve the final space before the animation starts, using an aspect-ratio box or a fixed-height skeleton, and animate within that
reserved space. And never animate an element into existence above content the user is already reading — a banner that expands at the top of the page pushes the
paragraph out from under their eyes, which is the single most annoying shift there is.
💡 Test on the device, not the laptop
A desktop GPU hides a great deal. Enable 4× CPU throttling in DevTools, or better, open the page on a real mid-range Android phone. Animations that trigger
layout are perfectly smooth on a development machine and visibly stutter on the hardware most of your visitors actually use.
Respecting Reduced Motion
@media (prefers-reduced-motion: reduce) {
/* Reset the initial state as well as the transition — disabling only the
transition leaves the element stuck at opacity: 0, hiding content from
exactly the people who asked for less movement. */
.reveal {
opacity: 1;
transform: none;
transition: none;
}
}
✅ Key takeaways
- ✓Animate
transform and opacity, nothing else. They are the only properties that reach the compositor.
- ✓Layout cascades. Triggering it pays for paint and composite as well, every frame.
- ✓Reserve space before animating into it. This is what turns a smooth animation into a zero-CLS one.
- ✓Use
will-change sparingly. Layer promotion costs GPU memory and can backfire on long lists.
- ✓Throttle the CPU when testing. Your laptop is not the device that will find the problem.
- ✓Reset state, not just transitions, under
prefers-reduced-motion.
More context in improving Core Web Vitals in React applications and
offloading heavy computation to Web Workers, which covers the other half of the
frame budget.
Frequently asked questions
Which CSS properties are safe to animate?
transform and opacity. Both are handled by the compositor and skip layout and paint. Animating width, height, top or left forces layout on every frame.
How do you achieve zero CLS with entrance animations?
Animate from a transform offset rather than from a height or margin, and reserve the element's final space up front. A layout-affecting entrance animation is a layout shift by definition.
Do Framer Motion animations hurt Core Web Vitals?
Only if they animate layout properties or run while off screen. Constrained to transform and opacity and gated on visibility, they are effectively free.
Home · Projects · Blog · Services · Résumé · Contact