Overview
A list whose rows arrive one after another, then keep animating as the list changes: new rows spring in, removed rows shrink out with a blur while the rest close the gap, and reordered rows glide to their new places. Four entrance styles (fade-up, blur, scale, slide), and it can wait until the list scrolls into view. The stagger shortens for long lists so the entrance never drags.
Installation
Install dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}
Copy the component code into your project:
tsx
"use client"; import { isValidElement, useEffect, useRef, type ReactNode } from "react";import { AnimatePresence, motion, useInView, useReducedMotion, type Variants } from "motion/react";import { cn } from "@/lib/utils"; export type StaggerListVariant = "fade-up" | "blur" | "scale" | "slide"; export type StaggerListProps = { /** Give each item a React `key` so adds, removals and reorders animate per row. */ items: ReactNode[]; className?: string; itemClassName?: string; variant?: StaggerListVariant; /** Run the entrance on mount, or when the list scrolls into view. */ trigger?: "mount" | "inView"; /** Seconds between items in the entrance. */ stagger?: number; /** Set to false to skip the entrance (items still animate when added or removed). */ animate?: boolean;}; const EASE_OUT = [0.23, 1, 0.32, 1] as const; function variantsFor(variant: StaggerListVariant, stagger: number): Variants { const hidden = variant === "blur" ? { opacity: 0, y: 10, filter: "blur(8px)" } : variant === "scale" ? { opacity: 0, scale: 0.9 } : variant === "slide" ? { opacity: 0, x: -24 } : { opacity: 0, y: 16 }; return { hidden, visible: (order: number) => ({ opacity: 1, x: 0, y: 0, scale: 1, filter: "blur(0px)", transition: { type: "spring", bounce: 0.15, duration: 0.55, delay: order * stagger }, }), exit: { opacity: 0, scale: 0.95, filter: "blur(4px)", transition: { duration: 0.2, ease: EASE_OUT }, }, };} export function StaggerList({ items, className, itemClassName, variant = "fade-up", trigger = "mount", stagger = 0.06, animate = true,}: StaggerListProps) { const reduce = !!useReducedMotion(); const ref = useRef<HTMLDivElement>(null); const inView = useInView(ref, { once: true, amount: 0.2 }); const show = trigger === "mount" || inView; // Only the first batch is staggered; items added later arrive on their own. const entered = useRef(false); const staggerEntrance = !entered.current; useEffect(() => { if (show) entered.current = true; }, [show]); const variants = variantsFor(variant, Math.min(stagger, 0.6 / Math.max(items.length, 1))); const skipEntrance = reduce || !animate; return ( <div ref={ref} role="list" className={cn("flex flex-col gap-2", className)}> <AnimatePresence initial={!skipEntrance} mode="popLayout"> {items.map((item, index) => ( <motion.div key={isValidElement(item) && item.key != null ? item.key : index} role="listitem" layout={reduce ? false : "position"} custom={staggerEntrance ? index : 0} variants={reduce ? undefined : variants} initial={skipEntrance ? false : "hidden"} animate={show ? "visible" : "hidden"} exit={reduce ? undefined : "exit"} transition={{ layout: { type: "spring", bounce: 0.15, duration: 0.45 } }} className={cn(itemClassName)} > {item} </motion.div> ))} </AnimatePresence> </div> );}
Adjust import paths according to your folder structure.
Basic Usage
tsx
import { StaggerList } from "@/components/ui/stagger-list"; <StaggerList variant="blur" trigger="inView" items={tasks.map((t) => ( <TaskRow key={t.id} task={t} /> ))}/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| items | ReactNode[] | Yes | — | Rows. Give each a React key so adds, removals and reorders animate per row. |
| variant | "fade-up" | "blur" | "scale" | "slide" | No | "fade-up" | Entrance style. |
| trigger | "mount" | "inView" | No | "mount" | Run the entrance immediately or when the list scrolls into view. |
| stagger | number | No | 0.06 | Seconds between rows, shortened automatically for long lists. |
| animate | boolean | No | true | Set to false to skip the entrance; changes still animate. |
| className / itemClassName | string | No | — | Classes for the list and for each row wrapper. |
Best Practices
- Always key your items by a stable id, not the array index, or rows will swap content instead of moving.
- Use the entrance for first loads and empty-to-full moments, not every re-render.
- Rendered as a
divlist with list roles, so page prose styles don't add bullets. - Reduced motion skips the entrance and layout movement.