The layout prop in Framer Motion is arguably its most magical feature. It abstracts away the complexity of FLIP (First, Last, Invert, Play) animations, allowing developers to animate between DOM states with a single boolean.
However, in complex dashboard applications—where state updates are frequent and DOM trees are deep—indiscriminate use of the layout prop is a performance killer. It leads to severe layout thrashing, dropping frame rates from 60fps to sub-15fps during state changes.
If your React Profiler shows massive "Layout" and "Update" times whenever a list reorders, you are likely over-scoping your animations.
The Root Cause: Unbounded FLIP Calculations
To understand why performance degrades, you must understand what Framer Motion does when layout is set to true.
When a component re-renders:
- Snapshot (Read): Framer Motion calls
getBoundingClientRect()on the element to measure its "Last" position. - Calculation: It calculates the delta between the old and new positions.
- Inversion (Write): It applies a CSS transform to invert the change instantly.
- Play: It animates the transform to zero.
The Thrashing Loop
The browser treats DOM read/write operations distinctly. If you read layout data (getBoundingClientRect) immediately after writing styles (React committing a render), you force a Synchronous Reflow.
When you apply layout to a parent container and its children, or a long list of items:
- React commits the DOM update.
- Framer Motion measures the parent.
- Framer Motion measures every child.
- Framer Motion calculates scale corrections (to prevent children from distorting when the parent resizes).
If you have 50 widgets on a dashboard, and the parent container has layout, Framer Motion must measure the parent and all 50 children. If those children contain nested layout components, the read/write cycle compounds. This computation happens synchronously on the main thread, blocking interaction.
The Solution: Scope, Scale, and Separation
To fix FPS drops, we must minimize the number of elements participating in the layout projection and eliminate unnecessary scale corrections.
Strategy 1: Remove layout from Containers
The most common mistake is adding layout to the wrapping div of a grid or list to make it resize smoothly when items are added/removed. Don't do this.
When a container animates its size (width/height), it distorts its children via CSS transforms. Framer Motion attempts to correct this by applying counter-transforms to every child. This is computationally expensive.
Strategy 2: layout="position"
If an element changes position but preserves its dimensions, use layout="position" instead of layout={true}. This tells Framer Motion to skip the expensive width/height delta calculations and scale correction logic.
Strategy 3: Localized LayoutGroups
Use <LayoutGroup> to scope shared layout animations so that changes in one part of the application do not trigger layout calculations in unrelated components.
Implementation: The Optimized Dashboard Grid
Below is a comparison of a naive implementation versus a rigorous, high-performance approach using Next.js (App Router compatible), TypeScript, and Tailwind CSS.
The High-Performance Fix
'use client';
import React, { useState, useId } from 'react';
import { motion, LayoutGroup, AnimatePresence } from 'framer-motion';
// Types
interface WidgetData {
id: string;
title: string;
size: 'small' | 'large';
}
const INITIAL_WIDGETS: WidgetData[] = [
{ id: '1', title: 'Revenue', size: 'large' },
{ id: '2', title: 'Users', size: 'small' },
{ id: '3', title: 'Sessions', size: 'small' },
{ id: '4', title: 'Bounce Rate', size: 'large' },
];
export const OptimizedDashboard = () => {
const [widgets, setWidgets] = useState(INITIAL_WIDGETS);
// Scoping the layout group ensures these calculations don't bleed
// into other parts of the app (like a sidebar or header).
const layoutGroupId = useId();
const toggleSize = (id: string) => {
setWidgets((prev) =>
prev.map((w) =>
w.id === id ? { ...w, size: w.size === 'small' ? 'large' : 'small' } : w
)
);
};
const removeWidget = (id: string) => {
setWidgets((prev) => prev.filter((w) => w.id !== id));
};
return (
<div className="p-8 max-w-4xl mx-auto">
<h1 className="text-2xl font-bold mb-6">Performance Optimized Grid</h1>
{/*
CRITICAL: The container is a standard HTML div.
It does NOT have the `layout` prop.
We rely on the browser's native flex/grid reflow, which is
highly optimized, and let the children animate themselves to their new spots.
*/}
<LayoutGroup id={layoutGroupId}>
<motion.div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
// We intentionally omit `layout` here to avoid parent-based scale correction
>
<AnimatePresence mode='popLayout'>
{widgets.map((widget) => (
<DashboardCard
key={widget.id}
data={widget}
onToggle={toggleSize}
onRemove={removeWidget}
/>
))}
</AnimatePresence>
</motion.div>
</LayoutGroup>
</div>
);
};
const DashboardCard = ({
data,
onToggle,
onRemove
}: {
data: WidgetData;
onToggle: (id: string) => void;
onRemove: (id: string) => void;
}) => {
return (
<motion.div
// BEST PRACTICE: Use layoutId only if morphing between distinct components.
// For reordering/resizing the same component, use `layout`.
layout
// OPTIMIZATION: If the element only moves (doesn't resize), use "position".
// Since our cards resize, we use `true`, but we explicitly set transition
// to avoid spring oscillation overkill on layout changes.
transition={{
layout: { duration: 0.3, type: 'spring', bounce: 0.2 },
opacity: { duration: 0.2 }
}}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className={`
bg-slate-800 rounded-xl p-6 shadow-lg border border-slate-700
${data.size === 'large' ? 'md:col-span-2' : 'col-span-1'}
`}
style={{
// Hardware acceleration hint for smoother transforms
willChange: "transform"
}}
>
<div className="flex justify-between items-start mb-4">
<motion.h3
// OPTIMIZATION: Only apply layout to children if they strictly need
// to move relative to the parent's internal flow.
layout="position"
className="text-white font-semibold"
>
{data.title}
</motion.h3>
<button
onClick={() => onRemove(data.id)}
className="text-red-400 hover:text-red-300 text-sm"
>
Remove
</button>
</div>
<div className="h-24 bg-slate-700/50 rounded-lg mb-4" />
<button
onClick={() => onToggle(data.id)}
className="text-sm text-blue-400 hover:text-blue-300 font-medium"
>
{data.size === 'small' ? 'Expand' : 'Collapse'}
</button>
</motion.div>
);
};
Technical Breakdown
1. Removing Container layout
In the code above, the wrapping .grid is a standard motion.div without the layout prop.
- Why: When items are removed, the grid naturally reflows. The remaining children detect their new positions relative to the viewport.
- Result: Framer Motion does not have to calculate the stretch/squeeze of the parent container and subsequently correct the scale of every child.
2. mode="popLayout"
In <AnimatePresence>, we use mode="popLayout".
- Why: Standard
exitanimations keep the element in the DOM flow, forcing siblings to wait until the animation finishes to snap into place. - Result:
popLayoutessentially appliesposition: absoluteto the exiting element immediately. The remaining siblings instantly detect a layout shift and animate to their new positions simultaneously with the exit animation, creating a snappier feel.
3. layout="position" vs layout
In the <h3> title, we use layout="position".
- Why: Text reflow is expensive. If the card expands, the text likely just moves. It rarely stretches.
- Result: By restricting the calculation to position only, we avoid calculating scale transforms for the text node, reducing the arithmetic load per frame.
4. will-change: transform
We manually add will-change: transform.
- Why: While browsers are getting better at promotion, explicit hinting ensures the card is on its own compositor layer before the animation starts, preventing painting during the transition.
When to use layoutId
The title mentions layoutId vs layout. Use layoutId only when two different components in the React tree represent the "same" visual entity (e.g., a list item clicking into a modal).
Do not use layoutId for list reordering. It incurs a global lookup cost across the LayoutGroup to match IDs. For simple reordering within a list, layout is faster because it operates on component instance persistence rather than ID matching across tree branches.
Conclusion
Animation performance is not about how fast the CSS transition runs; it is about how much work the main thread does before the transition starts.
By removing layout from parent containers, using mode="popLayout", and restricting text elements to layout="position", you decouple the layout logic. This ensures your framerate remains bound by GPU composition, not CPU layout calculation.