Skip to content
goodtabs UI
BrowseBrowse components

Stagger list

A list that enters in sequence with a step capped so the whole entrance stays under 300ms.

npx shadcn@latest add https://ui.goodtabs.co/r/stagger-list.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/stagger-list

  • Design
  • Build
  • Review
  • Ship

delay = min(50ms, 300ms / 4) = 50ms

Four items at the full 50ms step, with a replay button.

Twelve items

  • Item 1
  • Item 2
  • Item 3
  • Item 4
  • Item 5
  • Item 6
  • Item 7
  • Item 8
  • Item 9
  • Item 10
  • Item 11
  • Item 12

delay = min(50ms, 300ms / 12) = 25ms

The step shrinks to 25ms so the last item still starts within 300ms.

Usage

import { StaggerList } from "@/components/ui/stagger-list"

<StaggerList items={["One", "Two", "Three"]} />

Props

PropTypeDefaultDescription
itemsstring[]The labels to list.
replayKeynumber0Change it to play the entrance again.

Source

stagger-list/stagger-list.tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "cn";

type StaggerListProps = Omit<React.ComponentProps<"ul">, "children"> & {
  items: string[];
  /** Change this value to play the entrance again. */
  replayKey?: number;
};

/** Longest step between items, in milliseconds. */
const MAX_STEP = 50;
/** Longest total spread across the list, in milliseconds. */
const MAX_SPREAD = 300;

/**
 * A list that enters in sequence. step = min(50ms, 300ms / n), so a long list never
 * takes more than 300ms to start. Under reduced motion every item is simply there.
 */
export function StaggerList({ items, replayKey = 0, className, ...props }: StaggerListProps) {
  const reduce = useReducedMotion();
  const step = Math.min(MAX_STEP, MAX_SPREAD / Math.max(1, items.length));

  return (
    <div className="flex flex-col gap-2">
      <ul key={replayKey} className={cn("m-0 flex list-none flex-col gap-1 p-0", className)} {...props}>
        {items.map((item, i) => (
          <motion.li
            key={item}
            initial={reduce ? false : { opacity: 0, y: 6 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.15, ease: "easeOut", delay: (i * step) / 1000 }}
            className="rounded-md border bg-card px-3 py-1.5 text-sm"
          >
            {item}
          </motion.li>
        ))}
      </ul>
      <p className="font-mono text-xs text-muted-foreground tabular-nums">
        delay = min(50ms, 300ms / {items.length}) = {Math.round(step * 10) / 10}ms
      </p>
    </div>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/stagger-list/prompt.md, or the Copy AI prompt button above.

When to use

  • A short list that appears once, such as search results or menu entries
  • Explaining why a stagger step should shrink as the list grows

When not to use

  • Lists that update often: replaying the entrance each time is noise
  • Exit animations, which should stagger by no more than 20ms

Agent notes

  • Registry dependency button is only used by the demos, the component itself needs motion alone
  • Under reduced motion the items render in place with no initial state, so nothing is hidden
  • Only opacity and transform animate, 150ms each

The maths

  • Step = min(50ms, 300ms / n), so 4 items step 50ms and 12 items step 25ms.
  • Item i starts at i × step, so the last starts at (n - 1) × step, under 300ms.