Skip to content
Next.js+Framer Motion
Stack Integration

Next.js + Framer Motion: Animation Patterns

Framer Motion integrates with Next.js App Router to add production-quality animations — page transitions, scroll-triggered reveals, and gesture interactions — without fighting hydration.

Use Cases
  1. Scroll-triggered entrance animations on landing page sections
  2. Page transition animations between Next.js App Router routes
  3. Staggered list animations for cards, testimonials, and feature grids
  4. Gesture-driven interactions — drag, hover, and tap — on interactive components
Implementation

Mark components using Framer Motion with 'use client' — motion components are client-only. Use whileInView with a viewport={{ once: true }} prop for scroll-triggered animations that fire once and don't re-trigger on scroll back. Avoid mixing Tailwind transform utilities with Framer Motion's animate prop on the same element — they conflict. Split: apply Tailwind positioning classes to an outer <div> and Framer Motion animation props to the inner <motion.div>.

In detail

Where each part runs: Server Components meet a client-only library

Next.js App Router renders on the server by default. Framer Motion runs in the browser — it reads layout, measures elements, and drives requestAnimationFrame. So the boundary matters. A motion.div lives inside a file marked 'use client'; everything above it can stay a Server Component that streams static HTML.

The practical pattern is a thin client wrapper. Keep your page (app/page.tsx) as a Server Component for data fetching and SEO, then import a small 'use client' component that holds the motion elements and receives data as props. The server sends real markup, React hydrates, and Framer Motion takes over the animation on the client. This keeps the animated subtree small instead of turning a whole route into a client bundle, which would forfeit streaming and server data access.

Page transitions need AnimatePresence in the right place

App Router unmounts the old route before the new one paints, so exit animations are the hard part. AnimatePresence only animates exits for children that were rendered in the same component, so it has to wrap content that persists across navigations — typically in a template.tsx (which remounts per navigation) rather than layout.tsx (which persists).

The usual shape is a client template.tsx returning <AnimatePresence mode="wait"><motion.div key={pathname}>{children}</motion.div></AnimatePresence>, with pathname from usePathname(). The changing key is what triggers exit + enter. Use mode="wait" so the outgoing route finishes before the incoming one animates. Note that App Router's standard behavior won't delay the route swap for a slow exit animation — keep exits short, and don't depend on exit timing for anything load-bearing.

Scroll, hydration, and the gotchas that bite

whileInView with viewport={{ once: true }} is the reliable choice for entrance reveals: it fires once via IntersectionObserver and won't re-trigger when the user scrolls back up. Without once, elements re-animate on every pass, which reads as a bug.

Two real failure modes. First, transform conflicts: don't put Tailwind transform utilities (-translate-x-1/2, scale-95) on the same element whose transform Framer Motion animates — Motion writes inline transform and clobbers the class. Split positioning onto an outer <div> and animation onto the inner <motion.div>. Second, initial-state flashes: an initial={{ opacity: 0 }} element is hidden until the client hydrates and the observer fires, so content below the fold can appear blank to a crawler or on a slow connection. Keep initial offsets modest and reserve heavy reveals for non-critical content.

Reduced motion and bundle weight

Respect the OS setting. Framer Motion exposes useReducedMotion(), which reads prefers-reduced-motion; gate large translate/scale animations behind it and fall back to a plain opacity fade or no motion at all. This is an accessibility requirement, not a nicety — vestibular users get motion sickness from big parallax and slide effects.

On bundle size, prefer the motion import from framer-motion and keep animated components lazy where they're below the fold; pairing a dynamic import() (via next/dynamic) with ssr: false is reasonable for purely decorative, heavy interactive pieces like drag canvases. For most reveal-on-scroll work, the static markup already ships from the server, so the only client cost is the library plus your small wrapper — measure it in the bundle analyzer before reaching for code-splitting you may not need.

Other integration guidesView all →
Related

Need this built?