DEV Community

Anas Sheikh
Anas Sheikh

Posted on

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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-have.


7. Where I Stop

The biggest lesson from client work: nearly every project has more animation opportunities than it needs. Not every card needs a hover lift, not every number needs to count up, not every element needs its own entrance animation. Overusing motion makes an interface feel busy and slower to actually use, even when each individual animation is fast.

What I actually animate, consistently: list items entering with a stagger, state changes like status badges or toggles, page transitions, and hover feedback on genuinely interactive elements like buttons. Static content, headings, paragraphs, layout structure, stays still.


Summary

Pattern Use it for
Small Client Component wrapper Keeping animation JS out of the Server Component tree
staggerChildren Lists that should feel sequential, not instant
AnimatePresence + key Elements changing state, not just appearing once
layout prop Lists where items are added or removed, like a live queue
template.tsx Page transitions that re-trigger on every navigation
useReducedMotion Respecting accessibility preferences, not just adding motion everywhere

The actual skill here is restraint more than technique. Framer Motion makes almost anything possible, staggered lists, complex page transitions, physics-based springs, but the projects that feel genuinely polished use a small, consistent set of motion patterns applied deliberately, not every available effect applied everywhere.

I use this exact set, stagger on lists, state transitions with AnimatePresence, reduced motion support, across the dashboards and templates I build.

See it in a real codebase: https://neurodash-dashbord.vercel.app/

Get the templates: https://pixelanas.gumroad.com

Do you lean toward subtle motion or go heavier on animation in your projects? Drop it below ๐Ÿ‘‡


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)