Why Your React Animations Feel Slow Even at 60 FPS
You open the Performance panel. The animation is running around 60 FPS. There are no spectacular red warnings, nothing is visibly freezing, and your laptop is barely interested.
Yet the interaction still feels slow.
The cursor seems to chase the pointer instead of following it. A scroll reveal arrives just after you expect it. A draggable card has a slightly rubbery disconnect from your hand. A menu technically animates smoothly, but opening it feels heavier than clicking the same control without motion.
This is where an FPS counter can send debugging in the wrong direction.
60 FPS tells you how many frames arrived. It does not tell you whether they arrived well.
Animation quality is a combination of frame consistency, input latency, browser rendering work, JavaScript execution and the actual motion decisions you made. React can participate in those problems, but “React is slow” is rarely a useful diagnosis.
The better question is: what kind of slow are you looking at?
60 FPS Is a Target, Not a Diagnosis
On a 60 Hz display, the browser gets a new refresh opportunity roughly every 16.7 milliseconds. During that window it may need to execute JavaScript, recalculate styles, perform layout, paint pixels and composite layers before the updated frame reaches the display.
That is why 60 FPS became a familiar target for interface animation. A consistently delivered frame roughly every 16.7 ms can look smooth on a 60 Hz display.
The important word is consistently.
Imagine an FPS overlay reports an average around 60 over a measurement window. That number does not tell you whether individual frames were evenly spaced. A sequence with occasional slow frames followed by quicker frames can have a respectable average while still producing visible hitching.
Perceived motion follows the spacing of frames, not the optimism of the average.
This is also why hardcoding your animation around an assumption of exactly 60 updates per second is fragile. Displays running at 120 Hz and 144 Hz are common enough that requestAnimationFrame() may be called at those rates instead. MDN explicitly recommends using the timestamp provided to the callback when calculating animation progress.
let start;
function animate(timestamp) {
if (start === undefined) {
start = timestamp;
}
const elapsed = timestamp - start;
const progress = Math.min(elapsed / 600, 1);
element.style.transform = `translateX(${progress * 200}px)`;
if (progress < 1) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
The animation above is based on elapsed time rather than “move three pixels every frame.” That distinction keeps the duration stable when the display refresh rate changes.
You can read more about the behavior of requestAnimationFrame() on MDN.
Your Browser Has More Work to Do Than Draw the Animation
When an element changes visually, the browser does not simply receive your new value and teleport pixels into place.
Depending on what changed, it can move through several stages: style recalculation, layout, paint and composition. The amount of work depends partly on the CSS property being animated.
Animating something that changes geometry, such as width, left or margin, can require the browser to recalculate layout and repaint. Properties such as transform and opacity can often avoid layout and be handled later in the rendering pipeline.
Consider a simple horizontal movement.
// Can require layout work as the value changes
<div style={{ left: `${x}px` }} />
// Usually a better fit for visual movement
<div style={{ transform: `translateX(${x}px)` }} />
That does not mean transform is a magic performance keyword. You can animate only transforms and still build a sluggish interaction if each frame also performs expensive JavaScript, reads layout repeatedly, updates a large React subtree, draws an expensive canvas scene or runs several competing effects.
The property is one part of the frame. MDN has a useful breakdown of the browser rendering waterfall and animation property costs.
The practical debugging question is not just “Am I getting 60 FPS?” It is “What work has to complete before each visible update?”
React Can Make the Animation Compete With Your Application
React state is a great way to express application state. It is not automatically the best place to store every intermediate pixel of an animation. Suppose a pointer-following element writes its coordinates into component state for every pointermove event:
import { useState } from "react";
export default function Follower() {
const [position, setPosition] = useState({ x: 0, y: 0 });
return (
<div
onPointerMove={(event) => {
setPosition({
x: event.clientX,
y: event.clientY,
});
}}
>
<div
className="follower"
style={{
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
}}
/>
</div>
);
}
There is nothing inherently invalid about this code. The relevant question is what happens around it.
Every state update schedules React work. If Follower is isolated and the surrounding tree is cheap, the result may be perfectly acceptable. If the update causes substantial component work while other JavaScript is also running, you have made your animation share main-thread time with work that may not need to happen every frame.
For transient per-frame values, a ref or an animation system can sometimes keep React responsible for the meaningful state transition while letting interpolation happen outside repeated component renders.
import { useEffect, useRef } from "react";
export default function Follower() {
const followerRef = useRef(null);
const target = useRef({ x: 0, y: 0 });
const current = useRef({ x: 0, y: 0 });
useEffect(() => {
let frameId;
const tick = () => {
current.current.x +=
(target.current.x - current.current.x) * 0.18;
current.current.y +=
(target.current.y - current.current.y) * 0.18;
if (followerRef.current) {
followerRef.current.style.transform =
`translate3d(${current.current.x}px, ${current.current.y}px, 0)`;
}
frameId = requestAnimationFrame(tick);
};
frameId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameId);
}, []);
return (
<div
onPointerMove={(event) => {
target.current.x = event.clientX;
target.current.y = event.clientY;
}}
>
<div ref={followerRef} className="follower" />
</div>
);
}
This is not a rule that says “never animate through React state.” It is a separation-of-responsibilities question.
If a value exists mainly to produce another visual frame a few milliseconds later, ask whether the entire React render path needs to know about every intermediate value.
Profile before rewriting it.
requestAnimationFrame() Does Not Make Expensive Work Cheap
Moving code into requestAnimationFrame() is another optimization that can feel more powerful than it is.
requestAnimationFrame() tells the browser you want to run a callback before an upcoming repaint. It gives your animation work a browser-aware scheduling mechanism. It does not reduce the amount of work inside the callback.
This can still be expensive:
function tick() {
const cards = document.querySelectorAll(".card");
cards.forEach((card) => {
const rect = card.getBoundingClientRect();
card.style.transform =
`translateY(${window.innerHeight - rect.top}px)`;
});
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
Now you are querying elements, measuring layout and writing styles repeatedly. Put enough work in there and rAF will faithfully schedule an expensive callback every time the browser gives you the chance.
The useful question is:
What am I asking the browser to do inside each frame?
Cache what can be cached. Avoid unnecessary layout measurement inside hot loops. Keep cleanup explicit. Stop loops that no longer have a reason to run.
requestAnimationFrame() is a scheduler, not absolution.
Sometimes the Performance Problem Is Actually a Motion Problem
This is the category that profiling tools cannot solve for you. An interaction can be computationally cheap, maintain a steady frame rate and still feel slow because you told it to move slowly.
Imagine a cursor follower using this interpolation:
current += (target - current) * 0.05;
The browser may render it beautifully. No dropped frames. No expensive layout. No large React commits. It can still feel like the cursor is dragging a reluctant ghost behind it.
Change the interpolation:
current += (target - current) * 0.22;
The computational cost is effectively the same, but the interaction feels radically more immediate.
The same problem appears with animation durations, easing curves and springs. A navigation overlay with a long ease-in can feel resistant because the first part of the motion barely moves. A spring with too little stiffness or too much settling can make a button feel mushy. A parallax layer that deliberately trails scroll position may cross the line from depth into input lag.
A perfectly rendered delay is still a delay.
This matters because developers often respond to that feeling by optimizing code that is already fast enough. They memoize components, switch libraries or reach for will-change, only to discover that the animation still feels heavy.
Before doing that, temporarily shorten the duration or remove smoothing. If the interaction suddenly feels right while the performance trace barely changes, you found a motion-design problem rather than a rendering bottleneck.
Input-Coupled Motion Exposes Latency Faster
Not all animation has the same relationship with the user.
A decorative background can arrive slightly late without anyone having a precise expectation for where it should be. A button pressed under a cursor is different. So is dragging, scrolling, scrubbing or moving a custom pointer.
The user's hand provides a reference signal.
If their pointer is here and your visual follower is consistently there, the delay is obvious. If their finger moves a card and the card takes time to catch up, they feel the gap before they think about it technically.
This is why scroll smoothing deserves restraint. Smoothing the visual response can create a deliberate sense of weight, but too much of it makes the page feel as though scrolling has been routed through syrup.
The same applies to cursor trails. A trailing layer can be expressive while the actual clickable interaction remains immediate. Trouble starts when decorative latency leaks into controls that are supposed to feel direct. For input-driven effects, profile responsiveness and inspect your interpolation before chasing raw FPS.
“Use Transform and Opacity” Is Good Advice With Missing Footnotes
For many DOM animations, transform and opacity are sensible first choices because they can avoid the layout work triggered by geometry-changing properties. They should not become a superstition.
A fullscreen blur, huge translucent layer, complex SVG filter, large shadow or high-resolution canvas may still cost real rendering time even if its position changes through transform. A page running five independently smooth animation loops can become expensive when they all operate together.
Production pages are ecosystems. Your effect does not get a private browser.
This becomes especially noticeable on marketing sites where animation lives beside analytics, hydration, images, video, sliders, third-party scripts and application code. An isolated CodePen can tell you whether the effect works. It cannot tell you what happens after the effect moves into the page that has to ship.
Profile Frame Time, Not Just the Counter
When an animation feels wrong, resist the urge to start changing code randomly.
Reproduce the issue in the environment where it actually matters. Use a production build when possible. Development tooling and extra checks can distort performance characteristics.
Then record the interaction in your browser's Performance panel.
Look at what is occupying the main thread around the problematic frames. Is there a large JavaScript task? Repeated style recalculation? Layout work? Expensive paint? A React render arriving at exactly the wrong moment? Multiple callbacks continuing after their sections have left the viewport?
You are trying to identify a category of failure before choosing a fix.
A useful diagnostic table looks like this:
| What you feel | Inspect first |
|---|---|
| Animation visibly jumps | Frame timing, long JavaScript tasks, layout and paint |
| Motion is smooth but follows input late | Smoothing, interpolation, input handling and main-thread work |
| Animation struggles while UI data changes | React renders and competing JavaScript |
| Works on desktop, struggles on phone | CPU/GPU cost, image/canvas work and concurrent effects |
| Smooth everywhere but still feels heavy | Duration, easing, distance and spring tuning |
For deeper diagnostics, browsers are also gaining visibility into Long Animation Frames, which can help identify work contributing to long frames. Treat it as an additional signal rather than the one universal profiling API; support and tooling still deserve checking for your target browsers.
Test the Page, Not the Demo
An animation should be judged in the page where it will live.
That means testing it beside the real navigation, typography, images, scripts and adjacent interactions. It also means testing the actual devices you care about rather than using a high-end development machine as your minimum specification.
Mobile changes more than available processing power. Pointer assumptions disappear. Viewports become shorter. Touch scrolling behaves differently. Effects that feel appropriately restrained with a mouse can become annoying when attached to every finger movement.
Offscreen work matters too. If a canvas render loop or animation keeps producing frames after the user has left the section, it is still spending resources even though it contributes nothing to the current experience.
The production checklist is therefore broader than “does it hit 60?”
Ask whether the effect stops when appropriate. Ask whether it still reads correctly without motion. Check prefers-reduced-motion where the interaction calls for an alternative. Verify keyboard and touch behavior where controls are involved.
And test the whole page.
Source Access Is Useful Because Performance Decisions Are Contextual
This is also where reusable animation patterns get interesting.
A polished demo can give you the right interaction idea, but production usually requires changing something: timing, breakpoint behavior, scroll distance, pointer behavior, dependencies, cleanup, the number of animated elements, or what happens when motion is reduced.
That is why Hyperiux Vault uses a source-first model for its React and Next.js interaction effects. The effect files land in your project, so the implementation can be inspected and tuned rather than treated as a sealed animation runtime. That does not make an effect automatically fast. It makes the decisions visible.
A scroll interaction may need different behavior on a short mobile viewport. A cursor system should not assume a fine pointer exists on every device. A WebGL scene has a very different rendering budget from a CSS text reveal. Those decisions belong in the implementation, not in a blanket promise that every animation is “optimized.”
Vault's Scroll Effects guidance makes the same production distinction: use properties appropriate for animation, avoid unnecessary layout reads during scroll, clean up timelines and listeners, and test the effect on a real mid-range device before treating the implementation as finished.
If you want a starting point rather than another empty component file, you can browse the React and Next.js interaction effects in Vault and adapt the source to the page that actually has to ship.
A Better Way to Debug “Slow” Animation
The next time an animation looks smooth in an FPS overlay but feels wrong under your hand, split the problem into separate questions.
Is the browser delivering frames consistently? Is expensive work happening during those frames? Is React doing work that does not need to happen at animation frequency? Is the visual response arriving late after input? Or is the easing itself simply telling the interface to feel slow?
Those failures can look similar from the outside, but they need different fixes.
60 FPS is useful evidence. It just isn't a verdict.
Top comments (0)