# Stagger

> Turn one block into a sequence.

- Section: Motion
- URL: https://critly.vercel.app/stagger
- Published: 2026-07-15
- Source: https://github.com/kausthubh-coder/critly/blob/main/content/motion/stagger.mdx

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.

> **Interactive demo: Stagger.** Open https://critly.vercel.app/stagger to try it.

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

> **Interactive demo: Stagger Compare.** Open https://critly.vercel.app/stagger to try it.

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.

> **Interactive demo: Stagger Cap.** Open https://critly.vercel.app/stagger to try it.

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

**CSS**

```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);
}
```

**Motion**

```tsx
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

- [Details that make interfaces feel better](https://jakub.kr/writing/details-that-make-interfaces-feel-better): Jakub Krehel on splitting entering content into parts and staggering them.
- [Depth](https://rauno.me/craft/depth): Rauno Freiberg on choreographing elements so they don't all appear at once.
- [stagger()](https://motion.dev/docs/stagger): Motion's helper for spreading delays across children, used in the recipe below.
- [animation-delay](https://developer.mozilla.org/en-US/docs/Web/CSS/animation-delay): The property behind a CSS stagger, and how negative and long delays behave.
