Stagger & Entrance Sequences
8 minStagger & Entrance Sequences
A stagger is a sequenced reveal—multiple elements animating in with a slight delay between each. Done right, it guides the eye through a layout in reading order, creating a sense of orchestration. Done wrong, it makes the page feel like it's loading one piece at a time.
The difference is budget. Stagger sequences that complete within 300ms feel orchestrated. Stagger sequences that take 800ms feel like a broken waterfall loader. The total duration—not the per-item delay—is what the user perceives.
Section-level stagger
The most common stagger is section-level: a page loads and its major sections—hero, features grid, testimonials—animate in sequence. Each section is a single stagger unit.
The correct per-section delay is 80–120ms. With three sections, that's a total stagger of 160–240ms—well within the 300ms budget. The animation per section should be short: opacity from 0 to 1, translateY from 16–24px to 0, duration 300–400ms, ease-out.
<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. More items means the total duration exceeds the budget, and the last items feel like they're arriving late to a party that already started.
Word-level stagger
Word-level stagger splits a heading or sentence into individual words, each animating in with a slight delay. It's a dramatic, editorial effect—appropriate for hero headlines and splash screens, inappropriate for body text or repeated UI.
The per-word delay should be tighter: 40–80ms. Words are smaller cognitive units than sections, so the eye processes them faster. A five-word headline at 60ms per word completes in 240ms—acceptable. A twelve-word sentence at 60ms per word takes 660ms—too slow.
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>
The filter: blur(4px) on the initial state is optional but effective—words that resolve from a blur feel like they're coming into focus, adding a layer of metaphor to the reveal. Keep the blur subtle (4–6px). Higher values obscure the text enough to feel like a loading artifact.
Note the inline-block display. Spans are inline by default, and transform animations don't work on inline elements. Wrapping each word in an inline-block span preserves the natural word wrapping while enabling the translate animation.
Character-level stagger
Character-level stagger—animating each letter individually—is almost always wrong for product UI. It takes too long (a 20-character string at 30ms per character is 600ms), it's distracting, and it makes text unreadable during the animation.
The one exception: very short strings (3–5 characters) in decorative contexts—a counter ticking from "0" to "100," or a status badge revealing "LIVE." Even then, use a 20–30ms per-character delay and keep the individual character animation fast (150ms).
List item stagger
List items—search results, card grids, notification feeds—are the trickiest stagger context. The temptation is to stagger every item in a list of 20 results. This is wrong. Twenty items at 50ms each is a full second of animation—the user is waiting for the last result while the first result has been readable for 950ms.
For lists, stagger only the first 5–8 items and have the rest appear simultaneously with the last staggered item. This creates the perception of a stagger without the duration of a full sequence.
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
Icon swaps—a bookmark outline becoming a filled bookmark, a play button becoming a pause button—are a special case of entrance animation. The swap needs to feel instantaneous but not jarring.
The recipe: cross-fade with a subtle scale. The outgoing icon fades to 0 and scales to 0.8. The incoming icon fades from 0 and scales from 0.8 to 1. Total duration: 150ms. Add a slight filter: blur(2px) on both the exit and entrance for a softer transition.
<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>
The mode="wait" ensures the outgoing icon fully exits before the incoming icon enters—without it, both icons are visible simultaneously during the transition, creating a "double icon" flash.
The budget rule
Every stagger sequence should pass this test before shipping:
- Count the items.
- Multiply by the per-item delay.
- If the result exceeds 300ms, either reduce the per-item delay or cap the number of staggered items.
The user should never be waiting for your stagger to finish. If they are, the stagger isn't adding communication—it's adding delay. And delay, no matter how beautifully orchestrated, is still delay.