A list or grid whose items arrive one after another from any direction, then glide to their new places when filtered, sorted, added or removed.
Button
Primitives
Dialog
Overlays
Data table
Data
Tabs
Navigation
Command menu
Overlays
Calendar
Data
"use client"
import * as React from "react"
import { StaggerItem, StaggerList } from "@/components/ballmac/stagger-list"
const all = [
{ id: 1, name: "Button", tag: "Primitives" },
{ id: 2, name: "Dialog", tag: "Overlays" },
{ id: 3, name: "Data table", tag: "Data" },
{ id: 4, name: "Tabs", tag: "Navigation" },
{ id: 5, name: "Command menu", tag: "Overlays" },
{ id: 6, name: "Calendar", tag: "Data" },
]
const tags = ["All", "Primitives", "Overlays", "Data", "Navigation"]
export default function StaggerListDemo() {
const [tag, setTag] = React.useState("All")
const shown = all.filter((x) => tag === "All" || x.tag === tag)
return (
<div className="grid w-full max-w-md gap-3">
<div role="group" aria-label="Filter components" className="flex flex-wrap gap-1.5">
{tags.map((t) => (
<button
key={t}
type="button"
aria-pressed={tag === t}
onClick={() => setTag(t)}
className="h-8 rounded-full border px-3 text-[13px] font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:border-transparent aria-pressed:bg-primary aria-pressed:text-primary-foreground"
>
{t}
</button>
))}
</div>
<StaggerList inView={false} className="grid grid-cols-2 gap-2" aria-label="Components">
{shown.map((x) => (
<StaggerItem key={x.id} className="rounded-xl border bg-card p-3.5">
<p className="text-sm font-medium text-foreground">{x.name}</p>
<p className="text-xs text-muted-foreground">{x.tag}</p>
</StaggerItem>
))}
</StaggerList>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/stagger-listInstall the dependencies.
$ pnpm add motion@^12Copy the source into your project.
components/ballmac/stagger-list.tsx// Ballmac UI: Stagger List. https://ui.ballmac.com/components/stagger-list "use client" import * as React from "react" import { AnimatePresence, motion, useInView, useReducedMotion, type Variants } from "motion/react" import { cn } from "@/lib/utils" type StaggerDirection = "up" | "down" | "left" | "right" | "scale" function itemVariants(direction: StaggerDirection, distance: number): Variants { const from: Record<StaggerDirection, Record<string, number>> = { up: { y: distance }, down: { y: -distance }, left: { x: distance }, right: { x: -distance }, scale: { scale: 0.85 }, } return { hidden: { opacity: 0, ...from[direction] }, visible: { opacity: 1, x: 0, y: 0, scale: 1, transition: { type: "spring", stiffness: 260, damping: 26 } }, } } const Context = React.createContext<{ variants: Variants; reorder: boolean }>({ variants: itemVariants("up", 16), reorder: false }) type StaggerListProps = Omit<React.ComponentProps<"ul">, "ref"> & { /** Element for the list. */ as?: "ul" | "ol" | "div" /** Seconds between items. */ stagger?: number /** Seconds before the first item. */ delay?: number /** Where items come from. */ direction?: StaggerDirection /** Travel distance in pixels. */ distance?: number /** Wait for the list to scroll into view. */ inView?: boolean /** Items glide to their new place when the list is filtered, sorted or items are added or removed. */ reorder?: boolean } function StaggerList({ as = "ul", stagger = 0.07, delay = 0, direction = "up", distance = 16, inView = true, reorder = true, className, children, ...props }: StaggerListProps) { const ref = React.useRef<HTMLElement>(null) const reduce = useReducedMotion() const seen = useInView(ref, { once: true, margin: "0px 0px -10% 0px" }) const show = reduce || !inView || seen const variants = React.useMemo(() => itemVariants(direction, distance), [direction, distance]) const Tag = motion[as] as typeof motion.ul return ( <Context.Provider value={{ variants, reorder }}> <Tag ref={ref as React.Ref<HTMLUListElement>} data-slot="stagger-list" initial={false} animate={show ? "visible" : "hidden"} variants={{ hidden: {}, visible: { transition: { staggerChildren: reduce ? 0 : stagger, delayChildren: reduce ? 0 : delay } } }} className={className} {...(props as object)} > <AnimatePresence initial={false} mode="popLayout"> {children} </AnimatePresence> </Tag> </Context.Provider> ) } type StaggerItemProps = Omit<React.ComponentProps<"li">, "ref"> function StaggerItem({ className, children, ...props }: StaggerItemProps) { const { variants, reorder } = React.useContext(Context) const reduce = useReducedMotion() return ( <motion.li data-slot="stagger-item" variants={reduce ? { hidden: { opacity: 1 }, visible: { opacity: 1 } } : variants} layout={reorder && !reduce ? "position" : false} exit={reduce ? undefined : { opacity: 0, scale: 0.9, transition: { duration: 0.15 } }} className={cn(className)} {...(props as object)} > {children} </motion.li> ) } export { StaggerList, StaggerItem, type StaggerListProps, type StaggerItemProps }Update the import paths to match your project setup.
Usage
import { StaggerList, StaggerItem } from "@/components/ballmac/stagger-list"The full example is in the Code tab above.
Examples
Four directions
- 1
- 2
- 3
- 4
- 5
- 1
- 2
- 3
- 4
- 5
- 1
- 2
- 3
- 4
- 5
- 1
- 2
- 3
- 4
- 5
- 1
- 2
- 3
- 4
- 5
import { StaggerItem, StaggerList } from "@/components/ballmac/stagger-list"
const dirs = ["up", "down", "left", "right", "scale"] as const
export default function StaggerListDirections() {
return (
<div className="grid w-full max-w-md gap-4">
{dirs.map((direction) => (
<div key={direction} className="grid grid-cols-[4rem_1fr] items-center gap-3">
<span className="font-mono text-xs text-muted-foreground">{direction}</span>
<StaggerList inView={false} direction={direction} stagger={0.08} className="flex gap-1.5">
{[1, 2, 3, 4, 5].map((n) => (
<StaggerItem key={n} className="flex size-9 items-center justify-center rounded-lg border bg-card text-sm font-medium tabular-nums">
{n}
</StaggerItem>
))}
</StaggerList>
</div>
))}
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
asElement for the list. | "ul" | "ol" | "div" | "ul" |
staggerSeconds between items. | number | 0.07 |
delaySeconds before the first item. | number | 0 |
directionWhere items come from. | StaggerDirection | "up" |
distanceTravel distance in pixels. | number | 16 |
inViewWait for the list to scroll into view. | boolean | true |
reorderItems glide to their new place when the list is filtered, sorted or items are added or removed. | boolean | true |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | The real list markup is untouched; only opacity and position change |
| Reduced motion | Items are shown at once and reordering is instant |
Use with AI
<StaggerList stagger direction distance inView reorder><StaggerItem key={id}>…</StaggerItem></StaggerList>. Keep each item's key stable and filtering or sorting animates automatically. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Stagger List (@ballmac/stagger-list) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Result lists that filter or sort
- Feature grids that should arrive in sequence
Not for
- A live feed where new items push others down (animated-list)
- Plain fade-ins (blur-fade)
Registry JSON: https://ui.ballmac.com/r/stagger-list.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- shadcn/utils
Pairs well with
Animated List
A live feed where new items spring in at the top and the rest glide down, with leave animations, an item cap, an optional fading tail and a polite announcement for new entries.
Blur Fade
A wrapper that fades content in while it unblurs and slides a few pixels, once it scrolls into view, with a group that staggers its children.
Bento Grid
A responsive bento layout of feature cards that span columns and rows. Each BentoCard has a background visual slot, icon, title and description, and a link that slides up on hover or focus.
Animated Beam
An SVG beam that connects two elements with a curved path and sends a glowing gradient pulse along it. Follows layout changes, pauses off-screen, static under reduced motion.