Staggered List Entrance
Rows fade and lift into place about fifty milliseconds apart, top to bottom.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { Children } from "react";
import type { ReactNode } from "react";
import { motion, stagger, useReducedMotion } from "motion/react";
import type { Variants } from "motion/react";
/**
* Vibary · Staggered List Entrance
*
* Rows fade and lift into place a fraction of a second apart, top to
* bottom, so a freshly loaded list has somewhere for the eye to start.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Works with zero props; pass `children` to stagger your own rows.
* Requires the automatic JSX runtime (default since React 17).
*/
export type StaggeredListEntranceProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Your rows — each top-level child becomes one staggered row.
* Falls back to embedded sample rows. */
children?: ReactNode;
/** Gap between row starts in ms. Overrides the variant when set. */
staggerMs?: number;
/** List width — px number or any CSS length. */
width?: number | string;
/** Fires once the last row has settled. */
onComplete?: () => void;
};
type VariantConfig = {
lift: number;
staggerMs: number;
leadIn: number;
fadeSeconds: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: rows carry text, so they travel on a spring at or above
// critical damping — one soft landing, never a rebound. Variants change
// how far a row travels and how long the wave takes to cross the list.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// 6px and 40ms: present for long lists where a bigger wave would turn
// into a queue the user has to wait out.
subtle: {
lift: 6,
staggerMs: 40,
leadIn: 0.04,
fadeSeconds: 0.2,
spring: { type: "spring", stiffness: 440, damping: 42 },
},
// The all-purpose setting: 50ms apart is enough to read a direction of
// travel and short enough that eight rows are settled inside a second.
default: {
lift: 12,
staggerMs: 50,
leadIn: 0.06,
fadeSeconds: 0.24,
spring: { type: "spring", stiffness: 380, damping: 34 },
},
// Longer travel and a wider gap — for a short list that is the whole
// point of the screen.
playful: {
lift: 18,
staggerMs: 65,
leadIn: 0.08,
fadeSeconds: 0.28,
spring: { type: "spring", stiffness: 330, damping: 30 },
},
};
const SAMPLE_ROWS = [
{ name: "Acme Studio", note: "Invoice paid", amount: "$1,240.00" },
{ name: "Northwind Supply", note: "Awaiting fulfilment", amount: "$318.75" },
{ name: "Bright Labs", note: "Subscription renewed", amount: "$49.00" },
{ name: "Harbor Freight Co", note: "Refund issued", amount: "-$86.20" },
];
export default function StaggeredListEntrance({
variant = "default",
children,
staggerMs,
width = 320,
onComplete,
}: StaggeredListEntranceProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Reduced motion: every row still arrives, all at once and in place.
// The sequence was pacing, not information.
const lift = reduceMotion ? 0 : cfg.lift;
const interval = reduceMotion ? 0 : (staggerMs ?? cfg.staggerMs) / 1000;
const leadIn = reduceMotion ? 0 : cfg.leadIn;
const list: Variants = {
hidden: {},
visible: {
transition: { delayChildren: stagger(interval, { startDelay: leadIn }) },
},
};
const row: Variants = {
hidden: { opacity: 0, y: lift },
visible: {
opacity: 1,
y: 0,
transition: {
y: cfg.spring,
// Opacity runs on its own short curve; springing a fade only
// makes the row look hesitant.
opacity: {
duration: reduceMotion ? 0.15 : cfg.fadeSeconds,
ease: "easeOut",
},
},
},
};
const rows: ReactNode[] =
children != null
? Children.toArray(children)
: SAMPLE_ROWS.map((sample) => (
<SampleRow key={sample.name} {...sample} />
));
return (
<motion.ul
// Safari drops list semantics from a list with no bullets, so the
// role is restated rather than assumed.
role="list"
variants={list}
initial="hidden"
animate="visible"
style={{
width,
listStyle: "none",
margin: 0,
padding: 0,
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
{rows.map((content, index) => (
<motion.li
key={index}
variants={row}
onAnimationComplete={
index === rows.length - 1 ? onComplete : undefined
}
>
{content}
</motion.li>
))}
</motion.ul>
);
}
/** Embedded sample so the component renders something real with zero
* props. Replace it by passing your own rows as `children`. */
function SampleRow({
name,
note,
amount,
}: {
name: string;
note: string;
amount: string;
}) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "9px 10px",
borderRadius: 12,
}}
>
<div
style={{
width: 30,
height: 30,
flexShrink: 0,
borderRadius: 9,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(124, 124, 240, 0.16)",
color: "#7C7CF0",
fontSize: 12.5,
fontWeight: 700,
}}
>
{name.slice(0, 1)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, lineHeight: 1.3 }}>
{name}
</div>
<div style={{ fontSize: 11.5, opacity: 0.55, marginTop: 2 }}>
{note}
</div>
</div>
<div
style={{
fontSize: 13,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
}}
>
{amount}
</div>
</div>
);
}About this pattern
What a freshly fetched list should feel like. Each row fades in and lifts a few pixels, offset from the one above it by around fifty milliseconds — long enough for the eye to read a direction of travel, short enough that the whole group has settled well inside a second. The offset also gives the eye somewhere to land: it starts at the first row instead of taking in a wall of items that all appeared at once. Rows travel on a near-critically-damped spring, because anything springier turns text into jelly.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Issue tracker
Restrained sequencing as rows settle after a page loads.
Related patterns
- Optimistic Row InsertThe new entry lands at half opacity the instant it is asked for, then firms up when the server agrees.
- File Upload ProgressA file row's bar fills as bytes land while the percentage and the counter roll with it.
- Spinner to CheckThe waiting arc finishes its turn, closes into a full ring, and the confirming stroke lands inside it.