Skip to content

Stagger

When a list appears all at once, it reads as one block. When each row arrives a moment after the one above, it reads as a list. Stagger is that small offset between siblings.

Drag the slider. Every row fades and rises the same way. Only the gap between them changes.

  • LinearCycle 42 starts Monday9:12
  • VercelDeployment ready for review8:47
  • RaycastYour extension was approved8:20
  • NotionWeekly digest for Design7:55
  • FigmaAna left 3 comments7:31

Keep the gap between 30ms and 60ms. That is enough for your eye to pick up a direction, top to bottom. Past 100ms the last row is still arriving after you have started reading the first, and the detail turns into a wait.

Block vs sequence

The same list with and without a stagger. The animation on each row is identical.

Block
  • Linear9:12
  • Vercel8:47
  • Raycast8:20
  • Notion7:55
  • Figma7:31
40ms stagger
  • Linear9:12
  • Vercel8:47
  • Raycast8:20
  • Notion7:55
  • Figma7:31

The staggered list is not faster. It is easier to follow, because your eye gets a starting point and a direction. The block gives you five things at once.

Long lists

Stagger adds up. A 60ms gap is fine on five rows and terrible on twenty, where the last row starts 1,140ms after the first. For lists that can grow, cap the total instead of fixing the step.

  • Linear9:12
  • Vercel8:47
  • Raycast8:20
  • Notion7:55
  • Figma7:31
  • GitHub7:04
  • Stripe6:48
  • Slack6:30
  • Loom6:02
  • Resend5:40
60ms step, last row starts at 540ms

A good cap is about 300ms from the first row to the last. Divide that by the number of gaps and you have your step: 33ms for ten rows. For very long lists, stagger only the rows on screen and show the rest at once.

The rows are content, not decoration. Animate only opacity and transform so every row holds its space from the first frame, and keep rows clickable while they fade in.

Usage

styles.css
/* <li style="--index: 0">, <li style="--index: 1">, ... */
@keyframes rise-in {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: translateY(0); }
}

.list > li {
  animation: rise-in 360ms cubic-bezier(0.23, 1, 0.32, 1) both;
  animation-delay: calc(var(--index) * 40ms);
}
import { motion, stagger } from "motion/react";

const list = {
  visible: { transition: { delayChildren: stagger(0.04) } },
};

const item = {
  hidden: { opacity: 0, y: 8 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.36, ease: [0.23, 1, 0.32, 1] },
  },
};

<motion.ul initial="hidden" animate="visible" variants={list}>
  {rows.map((row) => (
    <motion.li key={row.id} variants={item}>
      {row.title}
    </motion.li>
  ))}
</motion.ul>

Keep both in the CSS shorthand. It sets animation-fill-mode, which holds each row at its first frame during the delay. Without it, rows flash at full opacity, disappear, then fade in. It is the most common stagger bug, and you only notice it once the delays get long.

Resources

Search concepts

Search for a command to run...