Skip to main content

Performance Optimization for Page Transitions

Page transition animations are only polished when they run at 60 FPS (16.7 ms per frame). A janky transition—dropping to 30–45 FPS—feels sluggish and unprofessional. The difference between smooth and janky is the difference between a $2B startup and a prototype.

This article covers profiling techniques, GPU acceleration, render optimization, and architectural patterns to keep animations butter-smooth on real devices. By the end, you will have tools to ship production-grade transitions.

Profiling Animation Performance

The first step is measuring. Chrome DevTools Performance tab shows frame time and identifies bottlenecks.

Recording a Frame

  1. Open DevTools (F12).
  2. Go to Performance tab.
  3. Click Record (Ctrl+Shift+E).
  4. Trigger the animation (click to open modal, navigate to detail page, etc.).
  5. Stop recording.
  6. Analyze the timeline.

A healthy frame is 16 ms or less. If any frame exceeds 16 ms, that frame drops. Multiple drops in a row cause visible jank.

Common Bottlenecks

  • Forced reflow: Reading layout properties (offsetWidth, getBoundingClientRect()) after writing (style.width = '100px') forces reflow. Framer Motion batches to avoid this, but custom code often causes it.
  • Main thread blocking: Heavy JavaScript computation (sorting arrays, encrypting data) blocks animation. Move to Web Workers if possible.
  • Layout thrashing: Writing, then reading, then writing again (e.g., setting height, reading scrollHeight, setting transform). Group reads and writes.

Example: Detecting Jank Programmatically

import { motion } from 'framer-motion';
import { useEffect, useRef } from 'react';

export function AnimationPerformanceMonitor() {
const frameCountRef = useRef(0);
const droppedFramesRef = useRef(0);
let lastTime = performance.now();

useEffect(() => {
const checkFrameRate = () => {
const now = performance.now();
const delta = now - lastTime;

// 16.67 ms is 60 FPS
if (delta > 16.67 * 1.1) {
droppedFramesRef.current += 1;
console.warn(`Frame drop: ${delta.toFixed(1)}ms (${frameCountRef.current} frames)`);
}

frameCountRef.current += 1;
lastTime = now;
requestAnimationFrame(checkFrameRate);
};

const animationId = requestAnimationFrame(checkFrameRate);
return () => cancelAnimationFrame(animationId);
}, []);

return null;
}

Add this to your app during animation. Console logs frame drops with timing.

GPU Acceleration: The Golden Rule

GPU-accelerated properties animate off the main thread, keeping the frame rate smooth. Non-accelerated properties block the main thread.

Accelerated Properties

PropertyGPUNotes
transformYesPosition, scale, rotation, skew. Always animate this.
opacityYesFade effects. Safe for 60 FPS.
widthNoTriggers layout recalculation. Avoid.
heightNoTriggers layout recalculation. Avoid.
top, leftNoPosition-based; triggers layout. Use transform: translateX() instead.
background-colorNoTriggers paint. Avoid for animations.

Wrong: Animating left

<motion.div
animate={{ left: 100 }}
transition={{ duration: 0.3 }}
style={{ position: 'absolute' }}
>
This animates off the GPU, causing jank.
</motion.div>

Each frame, the browser calculates layout, then paints. ~50 ms per frame = obvious jank.

Right: Animating x (transform)

<motion.div
animate={{ x: 100 }}
transition={{ duration: 0.3 }}
>
This animates on the GPU. Smooth 60 FPS.
</motion.div>

Transform is GPU-accelerated. The browser skips layout and paint for this property. ~16 ms per frame = butter-smooth.

Framer Motion uses transform by default for x, y, scale, rotate, so you rarely need to think about this. But custom animations using CSS @keyframes or useSpring must respect this rule.

Render Optimization: Code Splitting and Lazy Loading

Complex components mounted during animation slow things down. Defer loading until animation completes.

Example: Lazy Load Detail Page Content

import { motion, AnimatePresence } from 'framer-motion';
import { Suspense, useState } from 'react';
import { lazy } from 'react';

const DetailPage = lazy(() => import('./DetailPage'));

export function DetailModal({ isOpen, onClose }) {
const [contentReady, setContentReady] = useState(false);

return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onAnimationComplete={() => setContentReady(true)}
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
}}
>
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: 'white',
borderRadius: '8px',
padding: '20px',
maxWidth: '600px',
}}
>
{contentReady && (
<Suspense fallback={<div>Loading...</div>}>
<DetailPage />
</Suspense>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

The modal animates in first (light work). After animation completes (onAnimationComplete), detail content lazy-loads and renders. This keeps the initial animation fast.

Reducing Animated Elements

Animating many elements simultaneously stresses the browser. If you have 50 cards entering with stagger, consider:

  • Virtualize: Render only visible cards (react-window, react-virtual).
  • Batch: Animate the first 10 cards, lazy-load the rest.
  • Skeleton screens: Show placeholder cards during load; animate them to real content.
import { FixedSizeList } from 'react-window';
import { motion, AnimatePresence } from 'framer-motion';

export function VirtualList({ items }) {
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={60}
width="100%"
>
{({ index, style }) => (
<motion.div
key={items[index].id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
style={{ ...style, padding: '10px' }}
>
{items[index].name}
</motion.div>
)}
</FixedSizeList>
);
}

Virtual lists render 10–15 items at a time. When scrolling, off-screen items unmount; new ones mount. Animation only runs on visible items.

Easing Curve Impact

Complex easing (cubic-bezier with extreme control points) requires more calculations per frame. Simple easing is faster:

EasingSpeedUse
linearFastestConstant-speed motion (scrolling, loading bars).
easeIn / easeOutFastMost animations. Predefined curves; GPU-optimized.
easeInOutFastSymmetric motion (minimize, maximize).
cubic-bezier(0.17, 0.67, 0.83, 0.67)MediumCustom curves. Calculate once; reuse.
springSlowestPhysics-based; recalculates every frame.

For 60 FPS on low-end devices, use easeOut or easeInOut. Avoid spring unless necessary for elastic effects.

Motion Value Optimization

useMotionValue and useTransform create derived values without re-rendering. Use them for gesture-driven animations:

import { motion, useMotionValue, useTransform } from 'framer-motion';

export function ScrollDrivenOpacity() {
const scrollY = useMotionValue(0);
const opacity = useTransform(scrollY, [0, 100], [1, 0.5]);

return (
<motion.div
style={{ opacity }}
onScroll={(e) => scrollY.set(e.target.scrollTop)}
>
Content opacity follows scroll position without re-renders.
</motion.div>
);
}

Motion values update outside the React render cycle, so they don't trigger component re-renders. This is critical for scroll and drag animations on large lists.

Respecting prefers-reduced-motion

Users with accessibility preferences may have animations disabled. Respect this to avoid motion sickness:

import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';

export function RespectReducedMotion() {
const [prefersReduced, setPrefersReduced] = useState(false);

useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReduced(media.matches);

const handleChange = (e) => setPrefersReduced(e.matches);
media.addEventListener('change', handleChange);
return () => media.removeEventListener('change', handleChange);
}, []);

return (
<motion.div
animate={{ x: 100 }}
transition={{
duration: prefersReduced ? 0 : 0.3,
ease: 'easeOut',
}}
>
This animates unless user prefers reduced motion.
</motion.div>
);
}

When prefers-reduced-motion: reduce is set, duration: 0 skips animation entirely. The element appears at its final position without motion.

Core Web Vitals and Animations

Animations must not hurt Core Web Vitals:

  • LCP (Largest Contentful Paint): Animation should not delay initial render. Use initial={false} to skip entrance animation on page load.
  • INP (Interaction to Next Paint): Animation must respond quickly to user input. Keep transition duration under 400 ms.
  • CLS (Cumulative Layout Shift): Animation must not cause layout shifts. Use transform only, never width/height.

Example: Fast initial render with animation starting after page load:

export function OptimizedPageLoad() {
return (
<motion.div
initial={false}
animate={{ opacity: 1 }}
transition={{ delay: 1, duration: 0.3 }}
style={{ opacity: 0 }}
>
{/* Rendered immediately; animates after 1 second */}
</motion.div>
);
}

Debugging Animation Jank

If animations stutter on real devices:

  1. Record DevTools Performance while running the animation on the target device.
  2. Check frame time: If any frame exceeds 16 ms, something is blocking.
  3. Inspect tasks: Look for JavaScript tasks, layout/paint work. Click each task to see the code.
  4. Fix bottlenecks:
    • If JavaScript task: move work to Web Worker or defer it.
    • If layout: check for forced reflows (reads after writes).
    • If paint: simplify styles; avoid filter, box-shadow on animated elements.

Key Takeaways

  • Profile animations with Chrome DevTools; aim for 16 ms per frame (60 FPS).
  • Animate only transform and opacity for GPU acceleration.
  • Lazy-load content after animation completes to keep initial motion fast.
  • Virtualize lists; avoid animating 50+ elements simultaneously.
  • Respect prefers-reduced-motion for accessibility.
  • Use motion values (useMotionValue) for gesture-driven animation without re-renders.

Frequently Asked Questions

Can I achieve 60 FPS on budget Android phones?

Yes, if you follow GPU acceleration rules and avoid heavy computation during animation. Test on real devices (e.g., Moto G, Samsung A12). Expect to reduce duration or animated element count on low-end hardware.

Is Framer Motion slower than CSS animations?

No. Framer Motion generates transform-based animations, which are equally fast as CSS. Overhead is minimal and occurs before animation starts, not during.

Should I preload assets before animation?

Yes. Preload images, fonts, and code bundles before starting animation. Use <link rel="preload"> and dynamic import() to load code asynchronously.

Can I cancel an animation mid-flight to avoid jank?

Yes. Set animate to a different target or toggle AnimatePresence to exit. Framer Motion smoothly stops the current animation.

What is the overhead of measuring elements for layout animation?

Minimal. Framer Motion batches measurements per frame using requestAnimationFrame. Overhead is ~1–2 ms, regardless of element count.

Further Reading