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 motion

Add 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:

components/ui/stagger-list.tsx
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

PropTypeRequiredDefaultDescription
itemsReactNode[]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.
staggernumberNo0.06Seconds between rows, shortened automatically for long lists.
animatebooleanNotrueSet to false to skip the entrance; changes still animate.
className / itemClassNamestringNo—Classes for the list and for each row wrapper.

Best Practices

  1. Always key your items by a stable id, not the array index, or rows will swap content instead of moving.
  2. Use the entrance for first loads and empty-to-full moments, not every re-render.
  3. Rendered as a div list with list roles, so page prose styles don't add bullets.
  4. Reduced motion skips the entrance and layout movement.