Skip to content

Animations with Framer Motion

Aman Kumar Singh8 min read
Part 36 of 50From the React & Next.js Complete Guide series
Animations with Framer Motion — article by Aman Kumar Singh

In Keyboard Navigation and Focus Management, I made the case that accessibility work is invisible when it's done right and painfully visible when it isn't. Animation sits in the same category, but pointing the opposite direction. It's the first thing a user notices, and often the first thing cut when a deadline slips. Both articles are part of the React & Next.js Complete Guide (see React Fundamentals for Professionals for the start of the series), and both come back to the same underlying question: how much polish does a given interaction actually need?

Framer Motion has become the default animation library for React, and for good reason. It gives you declarative animations that read like regular JSX props. It handles exit animations for unmounting components, something neither the browser nor React does natively. And it has sane defaults for physics-based motion that makes UI feel responsive instead of robotic. But it's also easy to overuse. I've seen dashboards where every card, badge, and toast fades and slides on every render, and the result is exhausting rather than delightful.

This article covers where Framer Motion earns its place in a production app, how to use its core primitives correctly, and where I'd hold back and use plain CSS transitions instead.

Why reach for a library instead of CSS transitions

CSS transition and @keyframes handle a large share of UI animation needs: hover states, simple fades, color changes on focus. If that's all your app needs, adding a dependency is unnecessary weight. The case for Framer Motion shows up once you need any of the following, which CSS genuinely struggles with:

  • Exit animations. CSS can't animate an element as it's removed, because React unmounts the component and the DOM node disappears in the same tick. AnimatePresence keeps the node mounted just long enough to finish its exit animation.
  • Animating between arbitrary values driven by state, like a progress bar tied to a fetch, without hand-rolling a chain of requestAnimationFrame calls.
  • Shared layout transitions, where an element appears to move from one position in the tree to another (a card expanding into a modal) using layoutId.
  • Gesture-driven interaction: drag, tap, and hover states that respond to real interaction physics rather than a fixed-duration transition.

The tradeoff is bundle size and a learning curve for the animation-specific mental model: variants, orchestration, the difference between layout and transform animations. For a marketing page with a handful of fade-ins, that cost isn't worth it. For a SaaS product with modals, drawers, and drag interactions, it usually is.

The core primitives: motion, variants, and AnimatePresence

The motion component is Framer Motion's replacement for a regular DOM element. motion.div behaves like div, but accepts initial, animate, and exit props that describe states, plus a transition prop that describes how to move between them.

import { motion } from "framer-motion";

function Toast({ message }: { message: string }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: -16 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, y: -16 }}
      transition={{ duration: 0.2, ease: "easeOut" }}
      className="rounded-md bg-gray-900 px-4 py-2 text-white shadow-lg"
    >
      {message}
    </motion.div>
  );
}

initial is the state the element starts in when it mounts, animate is the state it animates to, and exit is the state it animates to before being removed. None of this fires the exit animation on its own, because React still unmounts the component immediately. That's what AnimatePresence is for: it wraps the conditionally rendered children and delays the actual unmount until the exit transition finishes.

import { AnimatePresence, motion } from "framer-motion";

function ToastList({ toasts }: { toasts: { id: string; message: string }[] }) {
  return (
    <div className="fixed right-4 top-4 flex flex-col gap-2">
      <AnimatePresence>
        {toasts.map((toast) => (
          <motion.div
            key={toast.id}
            initial={{ opacity: 0, y: -16 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -16 }}
            transition={{ duration: 0.2 }}
            className="rounded-md bg-gray-900 px-4 py-2 text-white shadow-lg"
          >
            {toast.message}
          </motion.div>
        ))}
      </AnimatePresence>
    </div>
  );
}

The key on each motion.div matters as much as it does anywhere else lists are rendered: AnimatePresence uses it to detect which children have been added or removed between renders, so it knows which ones to animate out rather than just removing instantly.

For anything more than two or three related elements, variants keep the animation definitions out of the JSX and let parent and child animations coordinate through a shared state name instead of duplicated inline objects.

import { motion } from "framer-motion";

const listVariants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: { staggerChildren: 0.06 },
  },
};

const itemVariants = {
  hidden: { opacity: 0, y: 8 },
  visible: { opacity: 1, y: 0 },
};

function AnimatedList({ items }: { items: string[] }) {
  return (
    <motion.ul initial="hidden" animate="visible" variants={listVariants}>
      {items.map((item) => (
        <motion.li key={item} variants={itemVariants}>
          {item}
        </motion.li>
      ))}
    </motion.ul>
  );
}

The parent only declares hidden and visible; the children inherit those same names through variants and get their own timing via staggerChildren, without the parent needing to know each child's individual transition values. That separation is what makes variants worth the extra indirection once a list grows past a couple of items.

Layout animations and their real cost

layout and layoutId are the two props that let Framer Motion animate position and size changes automatically, including across components that aren't the same DOM element.

import { motion } from "framer-motion";

function ExpandableCard({ isExpanded, id, title }: { isExpanded: boolean; id: string; title: string }) {
  return (
    <motion.div layoutId={`card-${id}`} className="rounded-lg border p-4">
      <motion.h3 layout className="font-semibold">
        {title}
      </motion.h3>
      {isExpanded && (
        <motion.p layout className="mt-2 text-sm text-gray-600">
          Expanded detail content goes here.
        </motion.p>
      )}
    </motion.div>
  );
}

Under the hood, layout works by measuring the element's position before and after a render, then animating the difference using CSS transforms rather than animating top, left, width, or height directly, which would trigger layout recalculation every frame. This is also why it's not free: Framer Motion has to run a measurement pass (effectively a getBoundingClientRect call) on every animated element whenever the surrounding layout might have changed, and that cost grows with the number of layout-animated elements on screen. A handful of cards animating a reorder is fine. A data table with a few hundred rows all wrapped in layout is a good way to introduce jank that's harder to diagnose than a plain slow render, because the cost shows up as measurement work rather than obvious render time.

The practical rule I use: reach for layout/layoutId for a small number of elements where the moving-between-positions effect is the actual point of the UI (a shared-element modal transition, a drag-to-reorder list with a handful of items). For a large list, animate opacity and transform on individual rows instead, and skip layout measurement entirely.

Respecting reduced motion and accessibility

Animation and accessibility intersect directly, and it's easy to ship a component that looks great and also triggers vestibular discomfort for users who've set prefers-reduced-motion: reduce at the OS level. Framer Motion exposes this through useReducedMotion, and the fix is usually to swap a transform-based animation for a simple opacity fade, or skip the animation and jump straight to the end state.

import { motion, useReducedMotion } from "framer-motion";

function AnimatedPanel({ children }: { children: React.ReactNode }) {
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.div
      initial={shouldReduceMotion ? false : { opacity: 0, y: -12 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: shouldReduceMotion ? 0 : 0.25 }}
    >
      {children}
    </motion.div>
  );
}

Passing initial={false} skips the entry animation entirely rather than just shortening it, which is the correct behavior for users who've explicitly opted out of motion, not just users who prefer it faster. This is a genuinely easy win: it's a few lines per component, and it directly serves users who'd otherwise have a legitimately bad experience with your product.

Where I'd hold back

The failure mode I see most in production codebases is animation applied indiscriminately, because the library makes it so easy to add. A few situations where I'd deliberately skip Framer Motion:

  • Simple hover and focus states. A CSS transition on background-color or transform: scale() is cheaper, doesn't need JavaScript to run, and works even if the JS bundle hasn't hydrated yet.
  • Loading skeletons. A CSS @keyframes shimmer is standard practice and doesn't need a component library involved.
  • Anything animating on every single re-render of a frequently-updating component. If a component re-renders on every keystroke or every websocket tick, wrapping it in motion and giving it an animate prop tied to that state will fire (or re-evaluate) the animation constantly, which is wasted work and often visually distracting rather than helpful.
  • Server Components. motion components are client-only; they need event handlers and browser APIs that don't exist during server rendering. Keep animated pieces in dedicated client components and pass down only the data they need, rather than converting an entire page to a client component just to animate one element inside it.

Framer Motion is genuinely good at what it does. The API itself is straightforward; the real skill is judging when an interaction benefits from spring physics and exit transitions, versus when it's decoration that adds bundle size without making the product feel any better.

Key takeaways

  • Reach for Framer Motion when you need exit animations, layout transitions, or gesture-driven interaction; plain CSS transitions handle simple hover and fade cases more cheaply.
  • `AnimatePresence` is what makes exit animations possible in React, because it delays the actual unmount until the exit transition finishes; without it, unmounting components disappear instantly.
  • `variants` keep animation definitions declarative and let parent and child animations coordinate through shared state names, which pays off once a list or panel has more than a couple of animated children.
  • `layout` and `layoutId` are powerful but not free: each layout-animated element requires a measurement pass, so use them for a small, deliberate set of elements, not an entire large list.
  • Check `useReducedMotion` and honor `prefers-reduced-motion` for anyone who's opted out of motion at the OS level; it's a small change with a real accessibility payoff.
  • Keep `motion` components client-side and out of frequently re-rendering pieces of the UI; animating on every keystroke or websocket tick is wasted work, not polish.

Frequently asked questions

Does Framer Motion work with Next.js Server Components?

Not directly. Anything using `motion` needs to be a client component, since it relies on event handlers and browser APIs that don't exist during server rendering. Keep animated pieces in a small client component and pass down only the props they need, rather than marking a whole page client just to animate one element.

What's the difference between `animate` and `layout` in Framer Motion?

`animate` describes target values for props you set directly, like opacity or scale. `layout` detects and animates changes in the element's actual position and size caused by layout shifts elsewhere on the page, by measuring the element before and after render and animating the difference with a transform.

Why isn't my exit animation playing when a component unmounts?

React removes the DOM node immediately on unmount, leaving no window for a plain `exit` animation to run. Wrap the conditionally rendered element in `AnimatePresence`, which keeps the node in the DOM until its exit transition completes.

Is Framer Motion too heavy to add just for a couple of animations?

If your needs are limited to hover states and simple fades, CSS handles that without any added dependency. The library earns its bundle cost once you need exit animations, shared layout transitions, or gesture handling that CSS can't express.

How do I stop Framer Motion animations from re-triggering on every re-render?

Make sure the values passed to `animate` only change when you actually want a new animation, not on every render of a frequently updating parent. A new object reference on every render can make Framer Motion re-evaluate the transition more than intended; memoize the variant objects or move the animated piece out of the frequently updating parent.

Should I animate large lists or tables with Framer Motion's layout prop?

I'd avoid it for large lists. Each `layout`-animated element requires its own measurement pass, and that cost scales with the number of elements. For big lists, animate opacity and transform on individual rows directly instead of relying on layout measurement across the whole set.

Related articles

Core Web Vitals for React Apps — Aman Kumar Singh
Image and Asset Optimization — Aman Kumar Singh
SEO with the Next.js Metadata API — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.