Framer Motion Animations That Don't Kill Performance: Patterns and Pitfalls
Framer Motion makes animations easy. It also makes performance problems easy.
Here's how to get smooth 60fps without the jank.
The Golden Rule: Animate Transform and Opacity Only
// Bad — triggers layout recalculation (expensive)
<motion.div animate={{ width: '200px', height: '100px', left: '50px' }} />
// Good — GPU-composited, no layout (cheap)
<motion.div animate={{ x: 50, scale: 1.1, opacity: 0.8 }} />
Properties that trigger layout: width, height, top, left, padding, margin.
Properties that don't: x, y, scale, rotate, opacity, skew.
Entry Animations
const fadeUp = {
hidden: { opacity: 0, y: 24 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: 'easeOut' },
},
}
function Card({ children }: { children: React.ReactNode }) {
return (
<motion.div
variants={fadeUp}
initial="hidden"
whileInView="visible"
viewport={{ once: true }} // only animate once, not on scroll back
>
{children}
</motion.div>
)
}
viewport={{ once: true }} is critical — without it, the animation fires every time the element enters the viewport.
Staggered Children
const container = {
hidden: {},
visible: {
transition: { staggerChildren: 0.1, delayChildren: 0.2 },
},
}
const item = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.3 } },
}
function ProductGrid({ products }: { products: Product[] }) {
return (
<motion.div
variants={container}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="grid grid-cols-3 gap-4"
>
{products.map(product => (
<motion.div key={product.id} variants={item}>
<ProductCard product={product} />
</motion.div>
))}
</motion.div>
)
}
Hover and Tap States
function Button({ children, onClick }: ButtonProps) {
return (
<motion.button
onClick={onClick}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
{children}
</motion.button>
)
}
Spring physics feel more natural than linear transitions for interactive elements.
Layout Animations
function ExpandableCard({ title, content }: CardProps) {
const [expanded, setExpanded] = useState(false)
return (
<motion.div layout onClick={() => setExpanded(!expanded)}>
<motion.h3 layout>{title}</motion.h3>
{expanded && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{content}
</motion.p>
)}
</motion.div>
)
}
layout prop automatically animates size changes — no explicit width/height animation.
AnimatePresence for Exit Animations
import { AnimatePresence, motion } from 'framer-motion'
function Notifications({ notifications }: { notifications: Notification[] }) {
return (
<AnimatePresence>
{notifications.map(notification => (
<motion.div
key={notification.id}
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100 }}
transition={{ duration: 0.2 }}
>
{notification.message}
</motion.div>
))}
</AnimatePresence>
)
}
Without AnimatePresence, elements just disappear — no exit animation.
Performance Tips
// 1. Use style prop for non-animating styles (skips motion overhead)
<motion.div animate={{ opacity: 1 }} style={{ position: 'absolute' }} />
// 2. Disable animations for users who prefer reduced motion
import { useReducedMotion } from 'framer-motion'
function AnimatedCard() {
const shouldReduce = useReducedMotion()
return (
<motion.div
animate={{ y: shouldReduce ? 0 : [0, -10, 0] }}
/>
)
}
// 3. Avoid animating hundreds of elements simultaneously
// Cap stagger at ~20 items, virtualize the rest
The AI SaaS Starter Kit ships with Framer Motion animations pre-configured on the landing page and dashboard — all using GPU-composited properties. $99 one-time.
Top comments (0)