Tutorials

Framer Motion stagger: animate a list in, then out

Stagger list items with Motion for React (formerly Framer Motion): variants, delayChildren with stagger(), exit with AnimatePresence and reduced motion.

· 7 min read

A stagger is the small delay between items in a group, so a list arrives one row after another instead of all at once. It is one of the cheapest ways to make an interface feel considered, and one of the easiest to overdo.

This guide builds a staggered list entrance and exit with Motion for React, the library that used to be called Framer Motion. It covers variants, the stagger() helper that replaces staggerChildren, exit animations with AnimatePresence, and reduced motion. Because this page does not load React, the live demo below is the same effect in plain CSS.

  • Inbox12
  • Drafts3
  • Sent48
  • Archive210
  • Spam0
Plain CSS: each row waits var(--i) × 60 ms

Framer Motion is now Motion

Framer Motion was renamed to Motion and is published on npm as motion. The React API is the same; only the import path changed. The framer-motion package is still published, so existing projects keep working, but new code should look like this:

npm install motion
// before
import { motion, AnimatePresence } from "framer-motion";

// now
import { motion, AnimatePresence, stagger, useReducedMotion } from "motion/react";

Everything in this article uses motion/react. If you are still on framer-motion, the same code works with the old import, as long as your version is recent enough to export stagger.

How stagger works with variants

Motion staggers through variants: named animation states that a parent passes down to its children. You set initial and animate on the parent only. Each child that has variants with the same names picks up the state change, and the parent's transition decides when each child starts.

The classic way to write this uses staggerChildren and delayChildren on the parent's transition:

import { motion } from "motion/react";

const list = {
  hidden: {},
  show: {
    transition: { delayChildren: 0.1, staggerChildren: 0.06 },
  },
};

const item = {
  hidden: { opacity: 0, y: 12 },
  show: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.4, ease: [0.33, 1, 0.68, 1] },
  },
};

export function StaggerList({ items }) {
  return (
    <motion.ul variants={list} initial="hidden" animate="show">
      {items.map((text) => (
        <motion.li key={text} variants={item}>
          {text}
        </motion.li>
      ))}
    </motion.ul>
  );
}

Read the timing as: wait 0.1 s, start the first item, then start one more item every 0.06 s. Motion durations and delays are in seconds, not milliseconds. With five items the last one starts at 0.1 + 4 × 0.06 = 0.34 s and finishes at 0.74 s.

The parent's variants are empty objects on purpose. The parent itself does not move; it only orchestrates. If you also want the container to fade, add opacity to its variants and when: "beforeChildren" to its transition so the items wait for it.

The newer API: delayChildren with stagger()

In current versions of Motion, staggerChildren and staggerDirection are marked deprecated in the type definitions. The replacement is to pass the stagger() helper to delayChildren:

import { motion, stagger } from "motion/react";

const list = {
  hidden: {},
  show: {
    transition: { delayChildren: stagger(0.06, { startDelay: 0.1 }) },
  },
};

This produces the same timing as the earlier example. stagger(interval, options) returns a function that Motion calls once per child to get that child's delay. The options are where it becomes more useful than the old number:

OptionWhat it doesExample
startDelayDelay before the first child, in secondsstagger(0.06, { startDelay: 0.1 })
fromWhere the wave starts: "first", "last", "center", "edges", "random" or an indexstagger(0.05, { from: "center" })
easeDistributes the delays along an easing curve instead of evenlystagger(0.1, { ease: "easeOut" })
gridTreats items as [columns, rows] and staggers by 2D distance from fromstagger(0.04, { grid: [4, 3] })

The same helper works outside React. With the vanilla animate() function you pass it to delay: animate("li", { opacity: 1 }, { delay: stagger(0.06) }).

Exit with AnimatePresence

React removes elements from the DOM immediately, so there is nothing left to animate. AnimatePresence keeps a removed child mounted until its exit animation finishes. Because exit accepts a variant name, the stagger carries over:

import { AnimatePresence, motion, stagger } from "motion/react";

const list = {
  hidden: {
    transition: { delayChildren: stagger(0.04, { from: "last" }) },
  },
  show: {
    transition: { delayChildren: stagger(0.06, { startDelay: 0.1 }) },
  },
};

const item = {
  hidden: { opacity: 0, y: 12, transition: { duration: 0.2, ease: [0.32, 0, 0.67, 0] } },
  show: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.33, 1, 0.68, 1] } },
};

export function Menu({ open, items }) {
  return (
    <AnimatePresence>
      {open && (
        <motion.ul key="menu" variants={list} initial="hidden" animate="show" exit="hidden">
          {items.map((text) => (
            <motion.li key={text} variants={item}>{text}</motion.li>
          ))}
        </motion.ul>
      )}
    </AnimatePresence>
  );
}

A transition placed inside a variant applies when the element animates to that variant. So the show transitions control the entrance and the hidden transitions control the exit, on the parent and on each item. Here the exit uses a 200 ms ease-in per item and runs bottom to top, with a shorter interval, which is the usual choice: leaving should be faster than arriving, because the user has already decided to move on.

Two rules make this work. The animated element must be a direct child of AnimatePresence, and it needs a stable key. Without the key, React cannot tell that the element was removed rather than replaced.

To animate single items as they are added or removed, put AnimatePresence inside the list instead, give each motion.li an exit prop, and add layout so the remaining rows slide up to close the gap. Pass initial={false} to AnimatePresence if the rows that are present on first render should not animate in.

Respect reduced motion

People who set "reduce motion" in their operating system still need to see content arrive, but not travel. The useReducedMotion hook returns true when that setting is on:

import { motion, stagger, useReducedMotion } from "motion/react";

export function StaggerList({ items }) {
  const reduce = useReducedMotion();

  const list = {
    hidden: {},
    show: { transition: { delayChildren: reduce ? 0 : stagger(0.06) } },
  };
  const item = {
    hidden: { opacity: 0, y: reduce ? 0 : 12 },
    show: { opacity: 1, y: 0, transition: { duration: reduce ? 0.2 : 0.4 } },
  };

  return (
    <motion.ul variants={list} initial="hidden" animate="show">
      {items.map((text) => <motion.li key={text} variants={item}>{text}</motion.li>)}
    </motion.ul>
  );
}

With reduced motion on, the list becomes a single short fade with no vertical movement and no wave. For a site-wide default, wrap the app in <MotionConfig reducedMotion="user">: Motion then turns off transform and layout animations for those users and keeps opacity and color changes.

The same stagger in plain CSS

The demo at the top of this page has no JavaScript in the animation itself. Each row carries its index in a custom property, and the delay is computed from it:

<ul class="stg-list stg-play">
  <li style="--i: 0">Inbox</li>
  <li style="--i: 1">Drafts</li>
  <li style="--i: 2">Sent</li>
</ul>
.stg-list.stg-play li {
  animation: stg-in 400ms cubic-bezier(.33, 1, .68, 1) both;
  animation-delay: calc(100ms + var(--i) * 60ms);
}

@keyframes stg-in {
  from { opacity: 0; transform: translateY(12px); }
}

@media (prefers-reduced-motion: reduce) {
  .stg-list.stg-play li { animation: none; }
}

The numbers match the Motion version: 100 ms start delay, 60 ms between rows, 400 ms per row with an ease-out cubic curve. animation-fill-mode: both (the both in the shorthand) keeps each row hidden during its delay. The keyframes only define from, so each row animates back to its normal style. To replay it, the demo removes the stg-play class, forces a reflow and adds it again.

If you ever need to move a CSS animation like this into React, the mapping is mostly mechanical. See converting CSS keyframes to Motion for the full table.

Timing guidelines

Copy a stagger you have seen

When a site's menu or card grid staggers in a way you like, the interval and easing are the parts that are hard to guess by eye. The Capture Motion extension records the keyframes, duration, delay and easing of every animated element on the page, so the stagger shows up as real per-item delays. The popup shows it as CSS, React or GSAP code you can copy, and you can also export JSON or a Figma Motion timeline. For a stagger already built, the animated avatar stack and the components in the gallery are good starting points.

Questions

Is staggerChildren deprecated?

In current versions of Motion it is marked deprecated in the type definitions, with delayChildren: stagger(interval) as the replacement. It still works, so older code keeps running, but new code should use stagger().

Why are my children not staggering?

Usually because a child sets its own animate or initial prop, which stops it inheriting the parent's variant. Give children only variants, with the same variant names as the parent, and let the parent own initial and animate.

What stagger interval should I use?

40 to 80 ms between items works for most lists. Keep the total under about 500 ms: for long lists, shrink the interval or only stagger the items that are visible on screen.

Can I stagger without React?

Yes. In plain CSS, give each item a custom property such as --i and set animation-delay: calc(var(--i) * 60ms). Motion's vanilla animate() also accepts delay: stagger(0.06).