Animated list
FreeA 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.
pnpm dlx shadcn@latest add https://caliperui.site/r/animated-list.jsonExport 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 #
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.
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.
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.