Animations in Next.js 15 with Framer Motion The Patterns I Actually Use
Most animation advice online is either a flashy demo with a dozen effects stacked on top of each other, or nothing at all. What actually makes a dashboard or product feel polished is usually the opposite of flashy: small, consistent motion that makes state changes feel intentional instead of instant and jarring. Here are the patterns I actually reach for.
1. The Setup
npm install framer-motion
Every animated element needs to be a Client Component, since Framer Motion relies on browser APIs and hooks that do not exist in a Server Component. The trick is keeping that boundary as small as possible, wrapping just the animated piece, not the whole page.
// components/FadeIn.tsx
'use client';
import { motion } from 'framer-motion';
export function FadeIn({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
);
}
// app/dashboard/page.tsx
import { FadeIn } from '@/components/FadeIn';
export default async function DashboardPage() {
const data = await getData(); // server-side, no client JS needed for this
return (
<FadeIn>
<DashboardContent data={data} />
</FadeIn>
);
}
The page itself stays a Server Component doing the actual data fetching. Only the thin FadeIn wrapper needs to be client-side, keeping the rest of the JavaScript bundle untouched.
2. Stagger Effects for Lists
A list of cards or rows that all fade in together feels flat. Staggering the animation slightly across items reads as far more polished for very little extra code.
// components/StaggerList.tsx
'use client';
import { motion } from 'framer-motion';
const container = {
hidden: { opacity: 0 },
show: {
opacity: 1,
transition: { staggerChildren: 0.06 },
},
};
const item = {
hidden: { opacity: 0, y: 12 },
show: { opacity: 1, y: 0 },
};
export function StaggerList({ items }: { items: { id: string; name: string }[] }) {
return (
<motion.div
variants={container}
initial="hidden"
animate="show"
>
{items.map((entry) => (
<motion.div key={entry.id} variants={item}>
{entry.name}
</motion.div>
))}
</motion.div>
);
}
staggerChildren: 0.06 means each child starts 60 milliseconds after the previous one. Small enough that the list still feels fast, noticeable enough to register as intentional motion rather than everything appearing at once.
3. Animating Between States, Not Just In
The most common mistake is animating an element appearing once and then leaving every state change afterward completely instant. A toggle, a status change, a value updating, deserves the same care.
// components/StatusBadge.tsx
'use client';
import { motion, AnimatePresence } from 'framer-motion';
export function StatusBadge({ status }: { status: 'pending' | 'active' | 'canceled' }) {
return (
<AnimatePresence mode="wait">
<motion.span
key={status}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.15 }}
>
{status}
</motion.span>
</AnimatePresence>
);
}
AnimatePresence handles the exit animation too, something plain CSS transitions cannot do cleanly, since the DOM element needs to stay mounted just long enough for the exit animation to finish before actually being removed. The key={status} is what tells Framer Motion this is a genuinely new element to animate, not the same one just re-rendering.
4. Animating a List Where Items Get Added or Removed
Combining AnimatePresence with a mapped list handles items entering and leaving cleanly, which matters for anything like a live queue or a to-do list.
// components/QueueList.tsx
'use client';
import { motion, AnimatePresence } from 'framer-motion';
export function QueueList({ entries }: { entries: { id: string; name: string }[] }) {
return (
<AnimatePresence>
{entries.map((entry) => (
<motion.div
key={entry.id}
layout
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
{entry.name}
</motion.div>
))}
</AnimatePresence>
);
}
The layout prop is doing real work here. When an item is removed, the remaining items smoothly slide up to fill the gap instead of snapping into their new position instantly, which is what makes a queue or list feel alive rather than just re-rendered.
5. Page Transitions in the App Router
Page transitions in the App Router need a bit more setup than a single-page app, since each route is its own Server Component tree by default.
// app/template.tsx
'use client';
import { motion } from 'framer-motion';
export default function Template({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
{children}
</motion.div>
);
}
template.tsx is a specific Next.js file convention. Unlike layout.tsx, it re-mounts on every navigation, which is exactly what a transition animation needs: a fresh mount to animate in from, rather than a layout that persists and never re-triggers.
6. Respecting Reduced Motion
Skipping this is an easy way to make a site actively worse for someone who has motion sensitivity set at the OS level. Framer Motion makes it simple to respect that preference.
// components/FadeIn.tsx
'use client';
import { motion, useReducedMotion } from 'framer-motion';
export function FadeIn({ children }: { children: React.ReactNode }) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.3 }}
>
{children}
</motion.div>
);
}
useReducedMotion reads the operating system's accessibility setting directly. Respecting it is a small amount of code for something that genuinely matters to a portion of real users, not just a nice-to
Comments
No comments yet. Start the discussion.