Stagger & entrance sequences
The difference between elements appearing and elements arriving
Total duration is what the user perceives, not per-item delay. 5 cards at 50ms each = 250ms: orchestrated. At 160ms each = 800ms: a broken loader.
}
/>
## Section-level stagger
Per-section delay: **80-120ms**. Three sections = 160-240ms total.
```tsx
<motion.div
initial="hidden"
animate="visible"
variants={{
visible: {
transition: { staggerChildren: 0.1 },
},
hidden: {},
}}
>
{sections.map((section) => (
<motion.section
key={section.id}
variants={{
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: [0.22, 1, 0.36, 1] },
},
}}
/>
))}
</motion.div>
```
Don't stagger more than 4-5 sections.
## Word-level stagger
Right for hero headlines. Wrong for body text or repeated UI.
Per-word delay: **40-80ms**. A five-word headline at 60ms = 240ms. A twelve-word sentence at 60ms = 660ms. Too slow.
```tsx
const words = "Motion is communication".split(" ");
<motion.h1 variants={{ visible: { transition: { staggerChildren: 0.06 } } }}>
{words.map((word, i) => (
<motion.span
key={i}
className="inline-block mr-2"
variants={{
hidden: { opacity: 0, y: 12, filter: "blur(4px)" },
visible: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 0.35, ease: [0.22, 1, 0.36, 1] },
},
}}
>
{word}
</motion.span>
))}
</motion.h1>;
```
Keep blur subtle (4-6px). Note `inline-block`: `transform` doesn't work on inline elements.
## Character-level stagger
Almost always wrong. A 20-character string at 30ms per character is 600ms.
Exception: very short strings (3-5 characters) in decorative contexts.
## List item stagger
Stagger only the first 5-8 items. The rest appear simultaneously.
```tsx
variants={{
visible: {
transition: {
staggerChildren: 0.04,
// Stop staggering after the 6th child
delayChildren: 0,
},
},
}}
// In the item variant, cap the delay
const itemVariant = {
hidden: { opacity: 0, y: 12 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: {
delay: Math.min(i * 0.04, 0.24), // Cap at 240ms
duration: 0.3,
},
}),
};
```
## Contextual icon swaps
Bookmark outline to filled. Play to pause. Cross-fade with subtle scale and blur. 150ms total.
```tsx
<AnimatePresence mode="wait">
<motion.div
key={isBookmarked ? "filled" : "outline"}
initial={{ opacity: 0, scale: 0.8, filter: "blur(2px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.8, filter: "blur(2px)" }}
transition={{ duration: 0.15 }}
>
{isBookmarked ? <BookmarkFilled /> : <BookmarkOutline />}
</motion.div>
</AnimatePresence>
```
`mode="wait"` ensures the outgoing icon exits before the incoming enters.