From Imperative to Declarative: Animating with Framer Motion in Next.js 16
Article content
The Gap Between Functional and Premium
Two developers can build the same feature. One ships a UI that works. The other ships a UI that feels alive — where elements enter with purpose, respond to interaction, and guide the user's eye without them noticing. The difference is almost never the code architecture. It's motion.
In Next.js 16 with the App Router, Framer Motion is the cleanest way to close that gap. We define what the UI should look like in each state, and the library handles all the interpolation between them — no manual timing, no imperative DOM manipulation.
Staggered Entry — Guiding the Eye
For a Senior Developer, good animation isn't about flash — it's about cognitive load management. Staggered entry animations guide the user's eye naturally across content, creating a sense of order that static pages lack.
Here's the full pattern used in this portfolio's Bento Grid:
'use client'
import { motion } from 'framer-motion'
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: { staggerChildren: 0.08 }
}
}
const item = {
hidden: { opacity: 0, y: 16 },
show: { opacity: 1, y: 0, transition: { duration: 0.4, ease: 'easeOut' } }
}
export function BentoGrid({ cards }: { cards: Card[] }) {
return (
<motion.div variants={container} initial="hidden" animate="show">
{cards.map(card => (
<motion.div key={card.id} variants={item}>
<Card {...card} />
</motion.div>
))}
</motion.div>
)
}The parent controls the timing, the children just inherit it. Swap staggerChildren to 0.04 for a snappier feel, 0.12 for something more deliberate.
Performance: Keep It at 60fps
Animations get expensive fast if you're animating the wrong properties. The rule is simple: only animate transform and opacity. Everything else triggers layout recalculation.
// ❌ Triggers layout — janky at 60fps
<motion.div animate={{ height: 200, top: 100 }} />
// ✅ GPU-accelerated — smooth at 60fps
<motion.div animate={{ scaleY: 1.2, y: 100 }} />In Next.js App Router, wrap motion components in 'use client' and keep them at the leaf level — don't make entire page layouts client components just to add an entry animation to one section.
Exit Animations with AnimatePresence
Entry animations are easy because the element already exists. Exit animations are where most implementations fall apart — React removes the node from the DOM before the browser has a chance to animate it out. AnimatePresence solves this by keeping the element mounted until its exit animation completes:
<AnimatePresence mode="wait">
{isOpen && (
<motion.div
key="modal"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
<Modal />
</motion.div>
)}
</AnimatePresence>Two details matter here. The key prop is mandatory — AnimatePresence tracks children by key, and without one your exit animation silently never runs. And mode="wait" makes the outgoing element finish leaving before the incoming one enters, which is what you want for tab switches and filtered lists; the default concurrent mode is better for toast stacks and notifications where elements are independent.
Layout Animations: The Prop That Replaces Fifty Lines
When a filtered list reflows or a card expands, animating the positions manually means measuring bounding boxes, calculating deltas, and choreographing transforms. Framer Motion's layout prop does all of it — it implements the FLIP technique (First, Last, Invert, Play) internally:
{filteredItems.map(item => (
<motion.div key={item.id} layout transition={{ type: 'spring', stiffness: 100 }}>
<Card {...item} />
</motion.div>
))}Every time the list order or contents change, each card smoothly slides to its new position. One word. The caveat: layout animations measure the DOM on every change, so keep them off lists with hundreds of items — for a blog grid or a tag-filtered gallery they're effectively free, but they're not built for virtualised tables.
Entrance Animations and Core Web Vitals
Here's the trap nobody warns you about: initial={{ opacity: 0 }} is rendered into the server HTML as an inline style. That means your hero content ships to the browser invisible and stays invisible until React hydrates and the animation plays. If that hero happens to be your Largest Contentful Paint element, your LCP now includes your entire hydration time plus your animation delay.
This is not hypothetical — this exact site had a 4.1-second homepage LCP because the terminal-style hero text faded in after a typed animation. The fix brought it under 200ms: render the largest text block statically in the server HTML, and restrict the motion wrapper around it to transform-only (initial={{ y: 16 }}, no opacity). A translated element still paints — an opacity-zero element doesn't.
The rule: never opacity-fade the largest text or image above the fold. Slide it, scale it slightly, or animate the elements around it — but let it paint on the first frame.
Respecting Reduced Motion
Vestibular disorders make large movements genuinely unpleasant for a meaningful slice of users, and they've told the operating system so. Framer Motion exposes that preference as a hook:
const shouldReduceMotion = useReducedMotion();
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 24 }}
animate={{ opacity: 1, y: 0 }}
/>The pragmatic pattern: keep opacity fades (they're fine for almost everyone) and zero out the translations and scales. For app-wide coverage, MotionConfig reducedMotion="user" handles it in one place instead of per-component.
Keeping the Bundle Honest
The full motion import is convenient but heavy for what most sites use. If your animations are entries, exits, and hovers — no drag, no complex gestures — LazyMotion cuts the animation runtime substantially:
import { LazyMotion, domAnimation, m } from 'framer-motion';
<LazyMotion features={domAnimation} strict>
<m.div animate={{ opacity: 1 }} />
</LazyMotion>Swap motion. for m. inside the tree and the strict flag will error if the full component sneaks back in. This matters most on marketing and content pages, where the animation library shouldn't cost more than the content it decorates.
Conclusion
Pick one interaction on your current project that feels "functional but flat" — a list that appears all at once, a card with no hover state, a modal that just pops in. Add a single motion.div with initial, animate, and a 300ms ease. That's the entire investment. If it feels better, you'll know exactly where to apply it next.
Sources & References
- Framer Motion Documentation
- "The Elements of User Experience" by Jesse James Garrett
- Next.js App Router: Client vs Server Components
- Web.dev: Animations and Performance
Suggested Reading
Architectural Note:This platform serves as a live research laboratory exploring the future of Agentic Web Engineering. While the technical architecture, topic curation, and professional history are directed and verified by Maas Mirzaa, the technical research, drafting, and code execution for this post were augmented by Gemini (Google DeepMind). This synthesis demonstrates a high-velocity workflow where human architectural vision is multiplied by AI-powered execution.