Stagger list
A list that enters in sequence with a step capped so the whole entrance stays under 300ms.
npx shadcn@latest add https://ui.goodtabs.co/r/stagger-list.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/stagger-list
- Design
- Build
- Review
- Ship
delay = min(50ms, 300ms / 4) = 50ms
Four items at the full 50ms step, with a replay button.
Twelve items
- Item 1
- Item 2
- Item 3
- Item 4
- Item 5
- Item 6
- Item 7
- Item 8
- Item 9
- Item 10
- Item 11
- Item 12
delay = min(50ms, 300ms / 12) = 25ms
The step shrinks to 25ms so the last item still starts within 300ms.
Usage
import { StaggerList } from "@/components/ui/stagger-list"
<StaggerList items={["One", "Two", "Three"]} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | string[] | The labels to list. | |
| replayKey | number | 0 | Change it to play the entrance again. |
Source
stagger-list/stagger-list.tsx
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "cn";
type StaggerListProps = Omit<React.ComponentProps<"ul">, "children"> & {
items: string[];
/** Change this value to play the entrance again. */
replayKey?: number;
};
/** Longest step between items, in milliseconds. */
const MAX_STEP = 50;
/** Longest total spread across the list, in milliseconds. */
const MAX_SPREAD = 300;
/**
* A list that enters in sequence. step = min(50ms, 300ms / n), so a long list never
* takes more than 300ms to start. Under reduced motion every item is simply there.
*/
export function StaggerList({ items, replayKey = 0, className, ...props }: StaggerListProps) {
const reduce = useReducedMotion();
const step = Math.min(MAX_STEP, MAX_SPREAD / Math.max(1, items.length));
return (
<div className="flex flex-col gap-2">
<ul key={replayKey} className={cn("m-0 flex list-none flex-col gap-1 p-0", className)} {...props}>
{items.map((item, i) => (
<motion.li
key={item}
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15, ease: "easeOut", delay: (i * step) / 1000 }}
className="rounded-md border bg-card px-3 py-1.5 text-sm"
>
{item}
</motion.li>
))}
</ul>
<p className="font-mono text-xs text-muted-foreground tabular-nums">
delay = min(50ms, 300ms / {items.length}) = {Math.round(step * 10) / 10}ms
</p>
</div>
);
}
For AI agents
Hand your agent this page, the markdown at /components/stagger-list/prompt.md, or the Copy AI prompt button above.
When to use
- A short list that appears once, such as search results or menu entries
- Explaining why a stagger step should shrink as the list grows
When not to use
- Lists that update often: replaying the entrance each time is noise
- Exit animations, which should stagger by no more than 20ms
Agent notes
- Registry dependency button is only used by the demos, the component itself needs motion alone
- Under reduced motion the items render in place with no initial state, so nothing is hidden
- Only opacity and transform animate, 150ms each
The maths
- Step = min(50ms, 300ms / n), so 4 items step 50ms and 12 items step 25ms.
- Item i starts at i × step, so the last starts at (n - 1) × step, under 300ms.