Rows arrive in one capped wave the first time, never on filters.
Motion primitivesno dependencies
01Preview
Deployments
acme-web · production
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup.
03Usage
import { StaggerIn } from "@/components/ui/stagger-in";
<StaggerIn as="ul" aria-label="Deployments">
{deploys.map((d) => (
<li key={d.id}>{d.message}</li>
))}
</StaggerIn>
// Motion-driven items can share the same capped timing.
<motion.li transition={{ delay: staggerDelay(index) }} />04Source
"use client";
import { useEffect, useRef, useState } from "react";
/**
* The entrance is plain CSS so it runs on the server-rendered HTML at first
* paint, before hydration, with nothing hidden if scripts never load. Each
* direct child reads its place from :nth-child, so there is no index to pass
* and no wrapper per item. Once the first wave has played the rule is removed:
* rows added, filtered or re-sorted later simply appear.
*/
// Positions past the cap all share its delay, so rules are only needed up to the largest cap.
const MAX_CAP = 16;
const CSS = `
@keyframes stealth-stagger-in { from { opacity: 0; transform: translateY(var(--stagger-distance)); } }
@keyframes stealth-stagger-fade { from { opacity: 0; } }
[data-stagger-in="play"] > * {
animation: stealth-stagger-in var(--stagger-duration) var(--ease-out-expo) backwards;
animation-delay: calc(var(--stagger-delay) + min(var(--stagger-i, 0), var(--stagger-tail)) * var(--stagger-step));
}
${Array.from({ length: MAX_CAP - 1 }, (_, i) => `[data-stagger-in="play"] > :nth-child(${i + 2}) { --stagger-i: ${i + 1}; }`).join("\n")}
[data-stagger-in="play"] > :nth-child(n + ${MAX_CAP + 1}) { --stagger-i: ${MAX_CAP - 1}; }
@media (prefers-reduced-motion: reduce) {
[data-stagger-in="play"] > * { animation-name: stealth-stagger-fade; animation-duration: 160ms; animation-delay: 0ms; }
}
`;
export type StaggerInProps = React.ComponentProps<"div"> & {
/** The list element to render. Every direct child is one staggered item. */
as?: "div" | "ul" | "ol" | "section" | "tbody";
/** Milliseconds between one item and the next. */
step?: number;
/** Items after this many arrive together with the last staggered one, so a long list never makes the reader wait for its tail. 1–16. */
cap?: number;
/** Milliseconds before the first item starts. */
delay?: number;
/** Milliseconds each item takes to arrive. */
duration?: number;
/** Pixels each item rises from. */
distance?: number;
/** Skip the entrance entirely, e.g. when the list is restored from a cache the user has already seen. */
disabled?: boolean;
};
/** The delay for item `index` under the same capped stagger, in seconds, for Motion-driven items. */
export function staggerDelay(index: number, { step = 22, cap = 8, delay = 0 }: { step?: number; cap?: number; delay?: number } = {}) {
return (delay + Math.min(index, cap - 1) * step) / 1000;
}
type Phase = "play" | "done";
/**
* Plays a capped stagger the first time items appear in it: on mount if it
* mounts with data, or when the first rows arrive after a loading state.
* Remount it (change its key) to play it again.
*/
export function StaggerIn({
as: Tag = "div",
step = 22,
cap = 8,
delay = 0,
duration = 360,
distance = 6,
disabled = false,
className,
style,
children,
onAnimationStart,
...rest
}: StaggerInProps) {
const tail = Math.min(Math.max(Math.round(cap), 1), MAX_CAP) - 1;
const [phase, setPhase] = useState<Phase>(disabled ? "done" : "play");
const timer = useRef<number>(undefined);
useEffect(() => () => window.clearTimeout(timer.current), []);
// The first item to start opens a window just long enough for the whole wave;
// anything that mounts inside it joins the wave, anything after it doesn't animate.
const handleStart = (e: React.AnimationEvent<HTMLDivElement>) => {
onAnimationStart?.(e);
if (e.animationName !== "stealth-stagger-in" && e.animationName !== "stealth-stagger-fade") return;
// Only this list's own items; a nested StaggerIn runs its own wave.
if ((e.target as Element).parentElement !== e.currentTarget) return;
if (timer.current !== undefined) return;
const wave = delay + tail * step + duration + 60;
timer.current = window.setTimeout(() => setPhase("done"), wave);
};
const Comp = Tag as "div";
return (
<Comp
data-stagger-in={disabled ? "done" : phase}
className={className}
style={
{
"--stagger-step": `${step}ms`,
"--stagger-delay": `${delay}ms`,
"--stagger-duration": `${duration}ms`,
"--stagger-distance": `${distance}px`,
"--stagger-tail": tail,
...style,
} as React.CSSProperties
}
onAnimationStart={handleStart}
{...rest}
>
<style href="stealth-stagger-in" precedence="default">
{CSS}
</style>
{children}
</Comp>
);
}05Props
StaggerIn
| Prop | Type | Default | Description |
|---|---|---|---|
| as | "div" | "ul" | "ol" | "section" | "tbody" | "div" | The list element. Each direct child is one staggered item; no per-item wrapper or index needed. |
| step | number | 22 | Milliseconds between one item and the next. |
| cap | number | 8 | Items after this many arrive with the last staggered one, so the tail never keeps the reader waiting. 1–16. |
| delay | number | 0 | Milliseconds before the first item starts. |
| duration | number | 360 | Milliseconds each item takes to arrive. |
| distance | number | 6 | Pixels each item rises from. |
| disabled | boolean | false | Skip the entrance, e.g. for a list restored from a cache the user has already seen. |
staggerDelay
| Prop | Type | Default | Description |
|---|---|---|---|
| index* | number | — | The item's position. Returns its delay in seconds under the same capped stagger. |
| options | { step?: number; cap?: number; delay?: number } | { step: 22, cap: 8, delay: 0 } | Milliseconds and cap, matching the component's props. |
06Notes
Behavior
- Plays once, the first time items appear: on mount if the data is already there, or when the first rows replace a loading state. Change its key to play it again.
- The first item to start opens a window just long enough for the wave; rows that mount inside it join, and rows added, filtered or re-sorted afterwards simply appear.
- Plain CSS on server-rendered markup: it starts at first paint, before hydration, and nothing stays hidden if scripts fail to load.
- Each item reads its position from :nth-child, so fragments and conditional rows need no index props. Only this list's own children count; a nested StaggerIn runs its own wave.
Motion
- Each item rises 6px and fades in over 360ms on the expo ease-out, 22ms after the one before it.
- The stagger stops at the 8th item, so a 12-row list lands in about 510ms instead of 600ms+, and the rows the reader starts on are already still.
- Reduced motion drops the rise and the stagger: every item fades in together over 160ms, or appears at once where the app already zeroes animations.
Accessibility
- Content is in the DOM from the first render; only opacity and transform animate, so screen readers and find-in-page see every row immediately.
- Adds no roles; pass aria-label to the list as usual. It never moves focus or changes the tab order.