Stagger
When a list appears all at once, it reads as one block. When each row arrives a few milliseconds after the one before, it reads as a list. Stagger is that small offset between siblings, and it is one of the cheapest ways to make an interface feel put together.
Drag the slider and replay. The rows here fade and rise, and the only thing that changes is the gap between them.
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
Around 30ms to 60ms the list feels alive without feeling slow. Push past 100ms and the last row is still arriving after the user has started reading the first. The effect stops being a detail and becomes a wait.
Block vs sequence
Here is the same list with and without a stagger. The per-row animation is identical.
Linear9:12
Vercel8:47
Raycast8:20
Notion7:55
Figma7:31
Linear9:12
Vercel8:47
Raycast8:20
Notion7:55
Figma7:31
The staggered version is not faster, and it is not more useful. It is just easier to parse. Your eye gets a starting point and a direction, top to bottom, and it follows along. The block version gives you five things at once.
Long lists
Stagger is multiplicative. A 60ms gap looks fine on five rows and terrible on twenty, because the last row shows up more than a second late. 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
A useful rule: the last item should start within about 300ms of the first. Divide that by the number of items and you have your step. For very long lists, stagger only the rows that are visible and let the rest render instantly.
Never block interaction
A stagger is decoration on top of content that is already there. The rows should be clickable while they are still fading in, and a user who scrolls past the animation should not be punished for it. Use opacity and transform so the elements take up their space from the first frame, and never gate interaction on the animation ending.
Usage
@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);
}
/* Or without a variable, for a fixed number of rows. */
.list > li:nth-child(2) { animation-delay: 40ms; }
.list > li:nth-child(3) { animation-delay: 80ms; }
.list > li:nth-child(4) { animation-delay: 120ms; }import { motion } from "motion/react";
const list = {
visible: { transition: { staggerChildren: 0.04 } },
};
const item = {
hidden: { opacity: 0, y: 8 },
visible: { opacity: 1, y: 0 },
};
<motion.ul animate="visible" initial="hidden" variants={list}>
{rows.map((row) => (
<motion.li key={row.id} variants={item}>
{row.title}
</motion.li>
))}
</motion.ul>Set animation-fill-mode: both (the both keyword in the shorthand above) or the rows will flash at full opacity during their delay, before the animation starts. It is the most common stagger bug and it is invisible until you set a long delay.
Resources
Details That Make Interfaces Feel Better - Jakub Krehel on the small timing choices, stagger included, that separate polished UI from the rest.
Depth - Rauno Freiberg on how sequence and timing create a sense of layers on a flat screen.
Orchestration in Motion - The staggerChildren and delayChildren options for staggering variants across a tree.
animation-delay - The MDN reference for the property that makes a CSS stagger possible.