Animation Performance in React

Why most React animations drop frames, how transform and opacity beat everything else, and when to reach for FLIP, Framer Motion, and Chrome DevTools.

It was a Thursday afternoon at the creator-video startup I led engineering at. We’d just shipped a new entry animation on creator profile pages, the one where the booking card slides in from the right. The product designer pinged me with a 15fps screen recording from a $200 Android. It looked like a flipbook somebody had stapled together in the dark. On her MacBook Pro it was fine. On the phone people actually use, it was embarrassing.

The fix wasn’t a fancy library swap. It was three lines of CSS and a habit change. Honestly, that’s true of most React animation work.

Why your React animation drops frames

Browsers do four things to put pixels on a screen: style, layout, paint, composite. The cheap ones are at the end. If your animation only touches the composite step, the browser can hand it to the GPU and run it on a separate thread from your JS. If it touches layout, the browser recomputes geometry every frame, on the main thread, the same one React is using to reconcile your component tree.

So when someone animates width or top or background-color, they’re paying a full layout-and-paint tax per frame. On the M1 you don’t notice. On a cheap Android, you notice instantly.

Only two CSS properties skip layout and paint entirely: transform and opacity. That’s it. Memorize that, and most “why is this jankey” questions answer themselves.

Stick to transform and opacity

Here’s the bad version of that profile entry animation. I’m not proud of it but we shipped this exact pattern in an early PR.

// ProfileEntry.tsx, the slow version, don't ship this
import { useState, useEffect } from "react";

type Props = { open: boolean };

export function ProfileEntryBad({ open }: Props) {
  const [width, setWidth] = useState(0);

  useEffect(() => {
    setWidth(open ? 360 : 0);
  }, [open]);

  return (
    <aside
      style={{
        width: `${width}px`,
        left: open ? "0px" : "-360px",
        transition: "width 240ms ease-out, left 240ms ease-out",
      }}
      className="bg-white shadow-xl h-full"
    >
      <BookingCard />
    </aside>
  );
}

Animating width and left forces the browser to re-lay-out the whole sidebar tree every frame. Anything inside the panel, the booking card, the avatar, the buttons, all get re-measured. On low-end phones the main thread can’t keep up and frames drop.

Here’s the version that actually runs at 60fps on a mid-tier Android.

// ProfileEntry.tsx, the fast version
import { type CSSProperties } from "react";

type Props = { open: boolean };

export function ProfileEntry({ open }: Props) {
  const style: CSSProperties = {
    transform: open ? "translate3d(0, 0, 0)" : "translate3d(-100%, 0, 0)",
    opacity: open ? 1 : 0,
    transition: "transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 180ms linear",
    willChange: open ? "transform, opacity" : "auto",
  };

  return (
    <aside style={style} className="w-[360px] bg-white shadow-xl h-full">
      <BookingCard />
    </aside>
  );
}

A couple of details. translate3d instead of translateX is a leftover habit that nudges older browsers into making a compositor layer. Modern Chrome does it for you, but it costs nothing to be explicit. will-change is the part people abuse. Set it only while the animation is running and remove it when idle. Leaving will-change: transform on every panel forever means the browser pre-allocates layers for everything, and you can blow out the GPU memory budget on mobile. Then everything gets slower. I’ve seen it.

The FLIP technique for layout shifts

OK so sometimes you actually need to animate something that changes layout. A list reorder. A card expanding. A drawer pushing content over. You can’t animate transform directly because the new position is determined by layout you haven’t done yet.

That’s what FLIP is for. First, Last, Invert, Play. You snapshot the current geometry, let layout happen, snapshot the new geometry, then apply an inverse transform so the element looks like it didn’t move, and animate the transform back to identity. The browser is now animating transform only. Cheap.

// useFLIP.ts
import { useLayoutEffect, useRef } from "react";

type Rect = { x: number; y: number; width: number; height: number };

export function useFLIP<T extends HTMLElement>(key: string | number) {
  const ref = useRef<T | null>(null);
  const prevRect = useRef<Rect | null>(null);

  useLayoutEffect(() => {
    const node = ref.current;
    if (!node) return;

    const nextRect = node.getBoundingClientRect();
    const prev = prevRect.current;

    if (prev) {
      const dx = prev.x - nextRect.x;
      const dy = prev.y - nextRect.y;
      const sx = prev.width / nextRect.width;
      const sy = prev.height / nextRect.height;

      if (dx || dy || sx !== 1 || sy !== 1) {
        node.style.transformOrigin = "top left";
        node.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`;
        node.style.transition = "none";

        requestAnimationFrame(() => {
          node.style.transform = "";
          node.style.transition = "transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1)";
        });
      }
    }

    prevRect.current = nextRect;
  }, [key]);

  return ref;
}

The double requestAnimationFrame you sometimes see in older FLIP code is a workaround for browsers that batched the style change. Modern Chrome and Safari are fine with one. Keep an eye on Firefox if you have a Firefox-heavy audience.

Framer Motion layout animations

If you write this kind of thing by hand more than once or twice, just use Framer Motion. Their layout prop is FLIP under the hood, plus a spring-based animation loop, plus an AnimatePresence that handles enter and exit. It’s the rare library where the abstraction earns its keep.

import { AnimatePresence, LayoutGroup, motion } from "framer-motion";

type Comment = { id: string; author: string; body: string };

export function CommentList({ comments }: { comments: Comment[] }) {
  return (
    <LayoutGroup>
      <ul className="space-y-2">
        <AnimatePresence initial={false}>
          {comments.map((c) => (
            <motion.li
              key={c.id}
              layout
              initial={{ opacity: 0, y: 8 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -8 }}
              transition={{ type: "spring", stiffness: 380, damping: 32, mass: 0.9 }}
              className="rounded-md border p-3"
            >
              <strong>{c.author}</strong>
              <p>{c.body}</p>
            </motion.li>
          ))}
        </AnimatePresence>
      </ul>
    </LayoutGroup>
  );
}

A note on motion math. Springs feel right because real-world motion is spring-shaped. Easings feel branded, designed, deliberate. My default is springs for anything functional (list reorder, drawer open, expanding card) and easings for the one or two branded micro-moments per page that are supposed to feel “designed”. stiffness: 380, damping: 32, mass: 0.9 is a starting point I keep coming back to. Snappy without being twitchy.

Respect prefers-reduced-motion

Some users genuinely can’t watch your animations. Vestibular disorders, migraines, just plain dislike. The system-level setting they flip is prefers-reduced-motion: reduce. Honoring it is not optional. Framer Motion has useReducedMotion built in, and you can short-circuit your own custom animations the same way.

import { MotionConfig, useReducedMotion } from "framer-motion";
import { type PropsWithChildren } from "react";

export function AppMotionProvider({ children }: PropsWithChildren) {
  const reduced = useReducedMotion();

  return (
    <MotionConfig
      reducedMotion="user"
      transition={
        reduced
          ? { duration: 0 }
          : { type: "spring", stiffness: 380, damping: 32 }
      }
    >
      {children}
    </MotionConfig>
  );
}

reducedMotion="user" tells Framer to respect the OS preference for any motion.* component without further wiring. For your own raw CSS transitions, wrap them in a media query.

.profile-entry {
  transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}

@media (prefers-reduced-motion: reduce) {
  .profile-entry {
    transition: none;
  }
}

This is also the part of the codebase that broke us once. At the creator-video startup, we were a few months into migrating to atomic CSS and a shared design system. Five PRs went out on a Friday afternoon (yeah, I know) migrating older Vue components. CI was green. About thirty minutes after deploy, support pinged: creator profile pages had no bio section. The new design-system bundle shipped a global reset that nuked padding and margin on <section>. A teammate hotfixed the bio with a padding-top override, which surfaced three more collapsed sections within the hour. We rolled back, scoped the reset to a data-attribute boundary instead of emitting it globally, and added Chromatic snapshots against the top 50 routes. About two hours of broken pages on a high-traffic Friday. Applied to animation: a bare * { transition: none } or a too-broad reduced-motion block will eat sections you didn’t expect. Scope the rule.

Profile in DevTools before guessing

The thing I see junior engineers do most often is open the React DevTools Profiler, see a 4ms commit, and conclude “React’s fine, must be CSS”. Sometimes true. Often not. React Profiler tells you commit time. It doesn’t tell you paint time, layer count, or whether the GPU is choking. Chrome’s Performance tab does.

The workflow is short:

  1. Open Chrome DevTools, Performance tab.
  2. Tick “Screenshots” and CPU throttling at 4x.
  3. Hit record, trigger the animation, stop record.
  4. Look at the Frames lane at the top. Anything red is a dropped frame.
  5. Scroll down to the Main thread. Yellow is scripting, purple is rendering (layout), green is painting and compositing.

If the Main thread is full of purple bars during your animation, you’re animating layout. Go back and switch to transform. If the Frames lane is clean but the animation still feels off, look at the GPU lane and the Layers panel (separate tab in Rendering). Too many layers is a real problem on mid-tier mobile.

There’s a war story I keep coming back to from a real-time trading platform I architected, low-latency Socket.io tier, around 10M concurrent connections at peak. Market opened on a Tuesday after a bank-holiday weekend and clients started reconnecting in a storm. p99 tick fan-out latency went from about 80ms to about 3s. My first move was to scale gateway pods 3x via the autoscaler. New pods came online, hit the storm head-on, and went CPU-bound within twenty seconds. I was feeding the fire. The real fix was on the client: jittered exponential backoff plus a tight per-IP rate limit at nginx. About fourteen minutes of degraded delivery during market open. The reason it stuck with me: when something stutters, the instinct is to add. More callbacks, more parallel transitions, more hints. The fix is almost always measure, then remove.

Takeaways

  • Animate only transform and opacity. Everything else profiles like a layout bug.
  • Use FLIP (by hand or via Framer Motion’s layout prop) when the thing you’re animating changes geometry.
  • Springs by default, easings for branded micro-moments. stiffness: 380, damping: 32 is a sane starting point.
  • prefers-reduced-motion is not a nice-to-have. Wrap it at the root and short-circuit transitions to zero duration.
  • Use will-change only while the animation is running. Set it on entry, remove it on completion.
  • Chrome Performance tab over React Profiler when you suspect jank. Different tools, different layers of the stack.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.