caliper uidocs

Animated list

Free

A list whose rows come and go gracefully: new rows open into place, removed rows close their gap and the rest glide to their new positions.

Installs components/ui/animated-list.tsxDepends on none beyond ReactMotion shared motion tokens
pnpm dlx shadcn@latest add https://caliperui.site/r/animated-list.json
3 notifications
  • Export finished

    2 min ago

  • Invoice INV-0417 sent

    just now

  • Bank sync completed

    5 min ago

When to use #

Use it for

  • Notifications, search results and chip rows that change while you look.
  • Any list where a jump would lose people's place.

Skip it when

  • Long lists that re-sort often; the glide becomes the show.
  • Tables, which have their own row behaviour.

Decisions #

Why it is built this way, so you know what to keep when you change your copy.

Behaviour

Focus follows the neighbour
When the row that holds focus closes, focus moves to the nearest row that stays, so keyboard users keep their place.
A removed row stays until it has closed
It is kept in place, inert, for the length of its exit, then dropped. Bringing it back mid-exit opens it again instead of adding a second one.

Styling

The gap belongs to the row
Each row carries its own spacing, so the space closes with the row and nothing below jumps at the end.

Installation #

terminal
pnpm dlx shadcn@latest add https://caliperui.site/r/animated-list.json

Adds its 3 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.

Usage #

This is the code behind the preview above.

example.tsx
import { Bell, X } from "lucide-react"
import { useRef, useState } from "react"
import { AnimatedList } from "@/components/ui/animated-list"
import { Button } from "@/components/ui/button"

const sampleNotifications = [
  { id: 1, title: "Export finished", when: "2 min ago" },
  { id: 2, title: "Invoice INV-0417 sent", when: "just now" },
  { id: 3, title: "Bank sync completed", when: "5 min ago" },
  { id: 4, title: "Teammate joined Forma Studio", when: "12 min ago" },
  { id: 5, title: "Payout scheduled", when: "Tomorrow" },
  { id: 6, title: "Rule retired: payouts", when: "1 h ago" },
];

export function AnimatedListDemo() {
  const [items, setItems] = useState(sampleNotifications.slice(0, 3));
  const next = useRef(3);
  const add = () => {
    const entry = { ...sampleNotifications[next.current % sampleNotifications.length]!, id: 100 + next.current };
    next.current += 1;
    setItems((list) => [entry, ...list]);
  };
  const shuffle = () => setItems((list) => [...list].sort(() => Math.random() - 0.5));
  return (
    <div className="grid w-full max-w-md gap-4">
      <div className="flex flex-wrap items-center gap-2">
        <Button size="sm" onClick={add}>Add item</Button>
        <Button size="sm" variant="secondary" onClick={() => setItems((list) => list.slice(1))} disabled={items.length === 0}>Remove first</Button>
        <Button size="sm" variant="ghost" onClick={shuffle}>Shuffle</Button>
        <span className="text-xs text-muted-foreground" role="status">{items.length} {items.length === 1 ? "notification" : "notifications"}</span>
      </div>
      <AnimatedList aria-label="Notifications">
        {items.map((item) => (
          <div key={item.id} className="flex items-center gap-3 rounded-surface border border-border bg-elevated px-3 py-2.5">
            <Bell aria-hidden="true" className="size-4 text-muted-foreground" />
            <div className="min-w-0 flex-1">
              <p className="m-0 text-sm font-medium">{item.title}</p>
              <p className="m-0 text-xs text-muted-foreground">{item.when}</p>
            </div>
            <Button size="icon" variant="ghost" className="size-8" aria-label={`Dismiss ${item.title}`} onClick={() => setItems((list) => list.filter((entry) => entry.id !== item.id))}>
              <X aria-hidden="true" />
            </Button>
          </div>
        ))}
      </AnimatedList>
    </div>
  );
}

API reference #

AnimatedList

component

Also accepts the native props of its root element and a ref.

PropTypeDefaultDescription
as"div" | "ol" | "ul""ul"
gapnumber8Space between rows in pixels. It belongs to each row, so it closes with the row.

Accessibility #

It is built on native elements, so roles and focus come from the browser.

No custom key handling; Tab and the native activation keys do the work.

Motion #

Transitions use the shared duration and easing tokens. Under prefers-reduced-motion those tokens collapse to instant and travel distances go to zero, so state changes still happen without movement.