Animation Performance in React: GPU Layers, will-change and the Right Tools
GPU layers, will-change, and the real cost of animating the wrong CSS properties in React — a practical breakdown that goes beyond the usual advice.
Why React Animations Drop Frames
You've seen it. A modal that slides in at 40fps on a mid-range Android. A sidebar that stutters on a MacBook when the CPU is doing anything else. The animation code looks fine — you're using CSS transitions, maybe even a reputable library — but the result is janky. Why?
The browser's rendering pipeline has two big stages that matter here: the main thread and the compositor thread. The main thread handles JavaScript, style calculation, layout, and paint. The compositor just takes pre-rasterized layers and composites them. When you animate properties that only require compositing — transform and opacity — the main thread barely needs to care. It can run your React re-renders and event handlers without interrupting the animation at all.
Animating almost anything else — width, height, top, left, margin, padding, border-radius on some browsers — triggers layout or paint on every frame. That's the main thread, blocking, at 16ms intervals. On a budget device in 2026, your $400 phone just can't hit 60fps when you're doing that.
Honestly, the fix is straightforward once you internalize one rule: animate transform and opacity only. Everything else is expensive by default. We'll look at tools that help you stay in that lane, and what to do when you genuinely can't avoid it.
GPU Layers: What They Are and How They Work
A GPU layer (or "compositor layer") is a texture that lives on the GPU. The browser uploads it once, and the compositor can move, scale, rotate, or change its opacity without touching the CPU. That's what makes transform: translateX(200px) cheap — you're just telling the GPU to reposition an already-uploaded texture.
Layers are created implicitly in certain situations: elements with a CSS animation or transition on transform or opacity, elements with position: fixed, iframes, video elements, and a few others. You can also trigger layer promotion manually, which brings us to will-change.
Worth noting: layers aren't free. Each layer consumes GPU memory. On a device with 2GB of RAM, creating hundreds of promoted layers — say, by slapping will-change: transform on every card in a long list — can actually make things worse. The GPU runs out of VRAM and starts swapping. You end up with worse performance than if you'd done nothing.
In Chrome DevTools (and Firefox's Performance panel), you can open the Layers panel to see exactly what's been promoted. Do this before reaching for will-change. If the layer is already being created during animation, you don't need the hint.
will-change: The Hint, Not the Fix
will-change tells the browser to allocate a GPU layer in advance, before the animation starts. Without it, there's a small cost at animation start while the browser promotes the element. With it, that cost is paid earlier — ideally during idle time — so the first frame is fast.
The correct usage is targeted and temporary. Add it when the user is about to trigger an animation, remove it when they're done. A common React pattern with Framer Motion or plain hooks looks like this:
``tsx
import { useState, useEffect, useRef } from 'react';
function AnimatedCard({ isHovered }: { isHovered: boolean }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
if (isHovered) {
ref.current.style.willChange = 'transform';
} else {
// Remove after transition ends to free the layer
const el = ref.current;
const cleanup = () => { el.style.willChange = 'auto'; };
el.addEventListener('transitionend', cleanup, { once: true });
}
}, [isHovered]);
return (
<div
ref={ref}
style={{
transition: 'transform 250ms ease-out',
transform: isHovered ? 'translateY(-4px)' : 'translateY(0)',
}}
/>
);
}
``
If you're using Tailwind, the will-change-transform utility class exists but has the same cost problem. Don't add it statically to a component that renders 200 times on a page. Add it dynamically on hover/focus, or rely on the browser — which since Chrome 107 does speculative layer creation pretty well on its own for short transitions.
In practice, most apps don't need to manually manage will-change at all. The cases where it genuinely helps are long animations (>300ms) that start on a cold element, and complex scenes with many moving parts where you want to pre-pay the promotion cost. For your average modal or tooltip, let the browser figure it out.
Look, the worst thing you can do is cargo-cult will-change: transform onto everything because you read it helps. Profile first. The DevTools Performance tab (or performance.mark() in code) will tell you where you're actually dropping frames.
The Right Libraries and When to Use Each
React has three realistic choices for production animations: CSS transitions/animations (browser-native), Framer Motion, and React Spring. Each has a different performance profile.
CSS transitions are the fastest because they run entirely off the main thread when you animate compositor-only properties. Zero JS overhead per frame. If you're building something like a hover state, a tooltip fade, or a slide-in sidebar — and you're only touching transform and opacity — CSS is the right answer. The glassmorphism components on Empire UI use exactly this approach: pure CSS backdrop-filter transitions that stay off the main thread.
``css
.card {
transform: translateY(0);
opacity: 1;
transition: transform 200ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 200ms ease;
}
.card:hover {
transform: translateY(-6px);
opacity: 0.95;
}
``
Framer Motion (currently at v11.x) uses its own animation engine that drives animations via JS on the main thread by default, but automatically hands off compositor-only animations to native CSS when it can. The layout prop is the one to be careful with — it uses transform to fake layout changes, which is clever, but it does require measuring DOM elements, which costs main-thread time. For physics-based animations or complex sequencing, Framer Motion is worth it. For simple fades and slides, it's overkill.
React Spring takes a different approach — it uses a spring physics model and drives values via JS every frame. That means it's always on the main thread. For most animations this is fine because the spring curve is computed cheaply, but if you're animating dozens of elements simultaneously, you'll see it in the profiler. Quick aside: React Spring's useSpring with to and from on transform values still hits the compositor on repaint, but the interpolation cost accumulates.
One more thing — if you're building UI components and want to see how animation-heavy styles perform across different design patterns, browse components on Empire UI and run them through DevTools. Real components under real conditions tell you more than synthetic benchmarks.
Profiling React Animations Without Guessing
The Chrome Performance panel is the baseline. Record a 3-second clip while triggering your animation, then look at the Main thread row. You want to see frames that are short (under 10ms ideally, under 16ms for 60fps). If you see long tasks — anything over 50ms marked in red — those are your janky frames. Click into them to see what's happening: layout thrashing, long style recalculation, paint.
For React-specific overhead, the React DevTools Profiler shows you which components re-rendered during the animation. This matters because if a parent re-renders on every frame of your animation, it might be doing unnecessary work. A common mistake: storing animation state (like a progress value or a boolean) in a state variable that causes the whole tree to re-render.
``tsx
// Bad: re-renders the entire tree 60 times per second
const [progress, setProgress] = useState(0);
useEffect(() => {
const id = requestAnimationFrame(() => setProgress(p => p + 1));
return () => cancelAnimationFrame(id);
}, [progress]);
// Better: mutate a ref or use a library that bypasses React state
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
let frame = 0;
let raf: number;
const tick = () => {
frame++;
if (ref.current) ref.current.style.transform = translateX(${frame}px);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
``
The PerformanceObserver API gives you programmatic access to long tasks and layout shifts in production. You can log these to your analytics service and catch regressions before users complain. Pair this with the gradient generator or other tools to test visual complexity at scale.
That said, don't optimize blindly. Profile on the actual hardware your users have. A smooth animation on your M3 MacBook might drop frames on a $250 Chromebook. Chrome's CPU throttling setting (6x slowdown in DevTools) is a decent proxy for low-end devices.
Practical Patterns That Prevent Performance Issues
A few patterns that consistently prevent animation performance issues in real React apps — not hypothetical ones.
Hoist animation state out of render-heavy trees. If you have a data table that renders 100 rows and you're animating a sidebar next to it, putting the sidebar's open/closed state in a context that the table also consumes will cause 100 rows to re-render on every state change. Use component composition or Zustand to isolate animation state so only the animated element cares about it.
Use `transform` for position, not `top`/`left`. This is 2026 and developers are still doing position: absolute; top: 0; animation to top: 200px. Don't. Calculate your offsets at mount time and animate via translateY. Same visual result, compositor-only execution.
``tsx
// Don't animate this
const badStyle = {
position: 'absolute' as const,
top: isOpen ? '0px' : '-200px',
transition: 'top 300ms ease',
};
// Animate this instead
const goodStyle = {
position: 'absolute' as const,
top: 0,
transform: isOpen ? 'translateY(0)' : 'translateY(-200px)',
transition: 'transform 300ms ease',
};
``
Avoid animating `box-shadow` for hover effects. box-shadow triggers paint on every frame. The classic trick is to animate opacity on a pseudo-element that has the final shadow state already painted. It's a bit more markup but the performance difference is dramatic — especially on long lists. The box shadow generator helps you dial in the right values, and then you animate the pseudo-element approach rather than the property directly.
Debounce re-renders that feed into animations. If a scroll event triggers state updates that drive an animation, requestAnimationFrame scheduling or a 16ms debounce prevents you from queuing more renders than the browser can actually paint.
Putting It All Together in a React Component
Here's a production-ready animated card that applies everything from this article — compositor-only properties, no will-change abuse, no state-driven re-renders for the animation itself:
``tsx
import { useRef, useCallback } from 'react';
export function PerfCard({ children }: { children: React.ReactNode }) {
const cardRef = useRef<HTMLDivElement>(null);
const handleMouseEnter = useCallback(() => {
if (!cardRef.current) return;
// Set will-change only when hover starts
cardRef.current.style.willChange = 'transform';
cardRef.current.style.transform = 'translateY(-6px) scale(1.01)';
}, []);
const handleMouseLeave = useCallback(() => {
if (!cardRef.current) return;
cardRef.current.style.transform = 'translateY(0) scale(1)';
// Clean up will-change after the transition
const el = cardRef.current;
el.addEventListener(
'transitionend',
() => { el.style.willChange = 'auto'; },
{ once: true }
);
}, []);
return (
<div
ref={cardRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={{
transition: 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1)',
// No will-change here — we add it dynamically
}}
>
{children}
</div>
);
}
``
No state changes. No re-renders. The DOM mutation is direct and the transition runs on the compositor thread. If you're using Framer Motion and want similar behavior, the whileHover prop achieves this with less code — Framer handles the will-change lifecycle internally since v10.0.
For more complex component patterns and live examples across design styles — from glassmorphism to neobrutalism — browse components to see how different aesthetics handle motion without sacrificing performance. Some of the heavier visual effects like backdrop-filter and layered shadows do have GPU costs, and seeing them in context helps you calibrate expectations.
FAQ
No. It promotes the element to a GPU layer in advance, which helps if you have a cold element starting a long animation. But each layer costs GPU memory, so adding it to many elements at once — like every card in a list — can actually degrade performance or cause memory pressure on low-end devices.
The layout prop in Framer Motion measures and recalculates element positions on the main thread before animating. It's smart about using transform to fake the layout change, but the measurement phase costs CPU time. If you're seeing jank, try removing layout and see if it improves — sometimes you can achieve the same effect with simpler initial/animate transforms.
Not fully, no. border-radius triggers paint in most browsers. The common workaround is to animate transform: scale() from a rounded element — you get a similar morphing effect and it stays compositor-only. It doesn't work for all cases, but for button or card hover effects it's usually close enough.
Open Chrome DevTools, go to the Performance panel, record your animation, and look at the Compositor and GPU rows at the top. If frames appear there without corresponding long tasks in the Main row, you're compositor-only. You can also open Rendering settings and enable 'Highlight compositing borders' to see layers visually.