# Animated List

The AnimatedList component streams its children in one at a time, springing each new entry into place and pushing the older ones down until they fall out of the bottom of the list.

- **Category:** Animation
- **Docs:** https://uibeats.com/docs/animation/animated-list
- **Registry:** https://uibeats.com/r/animated-list.json
- **License:** MIT
- **npm dependencies:** `lucide-react`, `motion`

## Installation

```bash
npx shadcn@latest add https://uibeats.com/r/animated-list.json
```

Or install the dependencies and copy the source below:

```bash
npm install lucide-react motion
```

## When to use

For a product that produces a feed: notifications, orders, deploys, alerts. A still screenshot cannot show that things keep arriving.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | `-` | One entry per child, emitted in order |
| `delay` | `number` | `1400` | Milliseconds between entries arriving |
| `max` | `number` | `4` | How many entries stay on screen before the oldest is dropped |
| `loop` | `boolean` | `true` | Start over once the last child has been emitted |
| `live` | `boolean` | `false` | Announce arrivals to screen readers, for genuinely live content |
| `className` | `string` | `''` | Additional CSS classes applied to the list |

## Example usage

```tsx
"use client";

import { CreditCard, GitPullRequest, Star, UserPlus } from "lucide-react";
import { AnimatedList } from "@/components/demo/animation/animated-list";

const events = [
  {
    icon: Star,
    title: "New star on ui-beats",
    meta: "just now",
    tone: "text-amber-500",
  },
  {
    icon: CreditCard,
    title: "Payment received · $49.00",
    meta: "1m ago",
    tone: "text-emerald-500",
  },
  {
    icon: UserPlus,
    title: "Priya joined your workspace",
    meta: "2m ago",
    tone: "text-brand",
  },
  {
    icon: GitPullRequest,
    title: "PR #218 ready for review",
    meta: "4m ago",
    tone: "text-sky-500",
  },
];

const AnimatedListUsage = () => {
  return (
    <div className="w-full max-w-sm">
      <AnimatedList delay={1300} max={3}>
        {events.map(({ icon: Icon, title, meta, tone }) => (
          <div
            key={title}
            className="flex items-center gap-3 rounded-xl border bg-card p-3 shadow-subtle"
          >
            <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted">
              <Icon className={`size-4 ${tone}`} />
            </span>
            <div className="min-w-0">
              <p className="truncate text-sm font-medium">{title}</p>
              <p className="text-xs text-muted-foreground">{meta}</p>
            </div>
          </div>
        ))}
      </AnimatedList>
    </div>
  );
};

export default AnimatedListUsage;
```

## Source

Save as `components/ui/animated-list.tsx`.

```tsx
"use client";

import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Children, useEffect, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";

interface AnimatedListProps {
  /** One entry per child, emitted in order. */
  children: ReactNode;
  /** Milliseconds between entries arriving. */
  delay?: number;
  /** How many entries stay on screen before the oldest is dropped. */
  max?: number;
  /** Start over once the last child has been emitted. */
  loop?: boolean;
  /** Announce arrivals to screen readers, for genuinely live content. */
  live?: boolean;
  className?: string;
}

/**
 * A feed where entries arrive one at a time, pushing the ones already there
 * down and out of the bottom of the list.
 *
 * Only `max` entries are mounted at once, so a feed that has been running for
 * an hour costs exactly what it did in its first second. The shuffle is a
 * layout animation rather than hand-computed offsets: each surviving entry
 * animates from wherever it was to wherever the new arrival pushed it.
 *
 * Nothing here is real notification content; if you wire it to some, turn on
 * `live` so the arrivals are announced rather than appearing silently.
 */
export function AnimatedList({
  children,
  delay = 1400,
  max = 4,
  loop = true,
  live = false,
  className = "",
}: AnimatedListProps) {
  const entries = Children.toArray(children);
  const prefersReducedMotion = useReducedMotion();
  // Total emitted so far. The newest entry is always `emitted - 1`, which
  // makes it its own stable key however many laps the list has run.
  const [emitted, setEmitted] = useState(1);

  const finished = !loop && emitted >= entries.length;

  useEffect(() => {
    if (entries.length === 0 || finished || prefersReducedMotion) return;
    const timer = setTimeout(() => setEmitted((count) => count + 1), delay);
    return () => clearTimeout(timer);
  }, [emitted, delay, entries.length, finished, prefersReducedMotion]);

  if (entries.length === 0) return null;

  // Reduced motion gets the finished list, not a silent animation: the point
  // of the component is the arrival, and there is no quiet way to show it.
  const visibleEntries = prefersReducedMotion
    ? entries.slice(0, max).map((node, position) => ({ key: position, node }))
    : Array.from(
        { length: Math.min(emitted, max, entries.length) },
        (_, position) => {
          const sequence = emitted - 1 - position;
          return { key: sequence, node: entries[sequence % entries.length] };
        },
      );

  return (
    <div
      role="list"
      aria-live={live ? "polite" : undefined}
      aria-relevant={live ? "additions" : undefined}
      className={cn("flex w-full flex-col gap-2", className)}
    >
      <AnimatePresence initial={false}>
        {visibleEntries.map(({ key, node }, position) => (
          <motion.div
            key={key}
            role="listitem"
            layout={!prefersReducedMotion}
            initial={{ opacity: 0, scale: 0.9, y: -28 }}
            animate={{
              // Older entries fade as they sink, so the list ends in a falloff
              // rather than a hard cut at the last row.
              opacity: 1 - position * 0.18,
              scale: 1,
              y: 0,
            }}
            exit={{ opacity: 0, scale: 0.94, transition: { duration: 0.2 } }}
            transition={{ type: "spring", stiffness: 340, damping: 34 }}
            style={{ transformOrigin: "top center" }}
          >
            {node}
          </motion.div>
        ))}
      </AnimatePresence>
    </div>
  );
}

export default AnimatedList;
```

## Credits

Written by [Claude Code](https://claude.com/claude-code).
