Moro
Gallery
IFoundations
01Why motion exists in UI
02Timing & duration
03Easing
04Springs vs tweens
IIExpression
05Anticipation
06Follow-through & overlapping action
07Squash, stretch & impact
08Arcs
IIIEngineering
09Performance
10Layout animations & FLIP
11Interruptibility & interaction
12Accessibility & restraint
Tier IIIEngineering09

Performance

What to animate, what to avoid, and why

The cheapest animation frame is the one that doesn't render. The second cheapest is the one that only touches the GPU. Most motion bugs (jank, dropped frames, inexplicable stuttering) trace to a single root cause: animating the wrong CSS property. This lesson is short because the rules are few, but violating them is the single most common mistake in web animation.

#The idea

When a browser renders a frame, it goes through three stages: layout (computing sizes and positions), paint (filling pixels), and composite (combining layers onto the screen). Each stage is progressively cheaper. A property that triggers layout also triggers paint and composite. A property that only triggers paint skips layout. A property that only triggers composite skips both.

Only two CSS properties animate at composite-only cost: transform and opacity. Both are GPU-accelerated on every modern browser. Everything else (width, height, top, left, padding, margin, filter usually) triggers at least paint, often layout, and can drop frames even on fast machines.

The rule: animate transform and opacity. For everything else, rethink the approach.

Want to animate a box getting wider? Don't animate width; use transform: scaleX(). Want to slide a menu in? Don't animate left; use transform: translateX(). Want to fade something? opacity, never visibility or display.

For fading color, color/background-color trigger only paint (not layout), so they're fine. But for position, size, or layout changes, always route through transform.

Canonical example

Open Chrome DevTools, Performance tab. Record a few seconds of any web app with heavy animation. Look for the green "Paint" and purple "Layout" bars. If you see paint/layout happening every frame during an animation, you're on a slow path. A well-built animation should show only green "Composite" bars.

#When it applies

  • Every animation you write. This is not a style choice; it's a performance invariant.
  • Long-running animations (loaders, idle animations). Even small per-frame costs compound.
  • Mobile and lower-powered devices. The margin for paint/layout is much smaller there.
  • Animations with many elements (staggered lists of 10+ items). Each wrong property multiplies.

#When it doesn't

  • Never. This is an engineering rule, not a guideline. There is essentially no case where animating width or height is the right answer when transform: scale is available.
  • Exception (narrow): clip-path is composited in most modern browsers and is acceptable for reveal-style animations.

#Implementation

Wrong: animates layout-triggering properties.

DontDoThis.tsx
// Triggers layout + paint on every frame
<motion.div animate={{ width: 300, height: 200, left: 100 }} />

Right: animates only transform + opacity.

DoThis.tsx
// Composite-only, GPU-accelerated
<motion.div
  animate={{
    scaleX: 1.5,    // use scaleX/scaleY for size
    scaleY: 2,
    x: 100,         // use x/y for position (Motion shortcuts for translate)
  }}
/>
  • Motion's x and y shortcuts compile to transform: translate3d(), which hints the GPU to promote the element to its own layer.
  • will-change: transform tells the browser to promote the layer preemptively. Use sparingly: overuse costs memory.

For a resizing card, use scale instead of width/height:

ResizingCard.tsx
// Don't:
<motion.div animate={{ width: isOpen ? 400 : 200 }} />
 
// Do: animate scale, adjust inner content to compensate
<motion.div
  style={{ width: 400, transformOrigin: "left" }}
  animate={{ scaleX: isOpen ? 1 : 0.5 }}
>
  <div style={{ transform: `scaleX(${isOpen ? 1 : 2})` }}>
    {/* inner content counter-scales to stay crisp */}
  </div>
</motion.div>

#Craft notes

  • Filters (blur, drop-shadow) are expensive. filter: blur() above 20px can drop frames on mid-range machines. If you must blur, keep it under 20px and avoid animating the blur value itself.
  • will-change: transform should be temporary. Add it before animation, remove it after. Keeping it permanently costs GPU memory.
  • React re-renders during animation = dropped frames. Animate via refs (useRef + direct style mutation) or through Motion's motion values (useMotionValue) to skip React's render cycle entirely.
  • Test on real devices. A mid-range Android phone, not your M-series MacBook. If it's smooth there, it'll be smooth everywhere.
  • CSS variables are cheap to read in animations but expensive to update in a deep component tree. Be wary of animating a CSS variable at the root.

#Exercises

  1. Profile a real animation. Open DevTools, Performance. Record any animation on a site you're building. Identify the stages: composite only (green), paint (green + yellow), layout (green + yellow + purple). What did you find?
  2. Break and fix. Take a good animation using transform. Rewrite it with left/top/width/height. Profile both. Measure the frame time difference.
  3. Find a laggy site. Browse until you find an animation that drops frames. Use DevTools to diagnose why. Usually it's paint/layout on the wrong property.

#Study this in the wild

  • Arc browser's tab animations. Every transition uses transforms. Profile it. Clean composite-only frames.
  • Framer Motion's own documentation. Matt Perry tests on old Android hardware. The library is built around this constraint.

#Further reading

  • CSS Triggers: what each property costs — reference chart of layout/paint/composite per property.
  • web.dev: Rendering performance.
  • Emil Kowalski: Performance.
Lesson 08
Arcs
Lesson 10
Layout animations & FLIP

On this page

The ideaWhen it appliesWhen it doesn'tImplementationCraft notesExercisesStudy this in the wildFurther reading

Built by David Umoru