Presence list
Animates list items when you add, remove, or move them.
- 01Review copy
- 02Check icons
presence-list-basic-demo.tsx
"use client";import { ArrowCounterClockwiseIcon, CheckIcon } from "@phosphor-icons/react";import { useState } from "react";import { Button } from "@/components/vip-ui/button";import { PresenceList } from "@/components/vip-ui/presence-list";const tasks = [ { id: "copy", title: "Review copy" }, { id: "icons", title: "Check icons" },];export function PresenceListBasicDemo() { const [items, setItems] = useState(tasks); return ( <div className="grid w-full max-w-sm gap-2"> <PresenceList items={items} getKey={(item) => item.id} aria-label="Tasks"> {(item) => ( <span className="flex items-center gap-3 rounded-xl border border-border bg-card px-3 py-2.5 text-sm shadow-[var(--shadow-card)]"> <span className="grid size-8 shrink-0 place-items-center rounded-lg bg-accent font-mono text-xs font-semibold text-accent-foreground"> {String(tasks.indexOf(item) + 1).padStart(2, "0")} </span> <span className="min-w-0 font-medium [overflow-wrap:anywhere]"> {item.title} </span> </span> )} </PresenceList> <div className="flex flex-wrap items-center justify-between gap-3"> <output aria-live="polite" className="text-sm text-muted-foreground"> {items.length ? `${items.length} remaining` : "All done"} </output> <Button variant="secondary" size="sm" onPress={() => setItems((current) => (current.length ? [] : tasks))} > {items.length ? ( <CheckIcon aria-hidden="true" /> ) : ( <ArrowCounterClockwiseIcon aria-hidden="true" /> )} {items.length ? "Complete tasks" : "Restore tasks"} </Button> </div> </div> );}Show more
Installation
CLI
Manual
The CLI adds the files, packages, theme variables, and Tailwind plugin that this component uses. Before you run it, set up your project.
npm
yarn
pnpm
bun
pnpm dlx shadcn@latest add https://vip-ui.dev/r/vip-presence-list.jsonAdding and reordering
Pass getKey so rows keep their identity when they enter, leave, or move.
- First item
- Second item
- Third item
presence-list-demo.tsx
"use client";import { CaretDownIcon, CaretUpIcon, PlusIcon, TrashIcon,} from "@phosphor-icons/react";import { useRef, useState } from "react";import { flushSync } from "react-dom";import { Button } from "@/components/vip-ui/button";import { PresenceList } from "@/components/vip-ui/presence-list";type Row = { id: number; label: string };const initialRows: Row[] = [ { id: 1, label: "First item" }, { id: 2, label: "Second item" }, { id: 3, label: "Third item" },];export function PresenceListDemo() { const [rows, setRows] = useState(initialRows); const controls = useRef(new Map<string, HTMLButtonElement>()); const control = (key: string) => (node: HTMLButtonElement | null) => { if (node) controls.current.set(key, node); else controls.current.delete(key); }; const update = (next: Row[], focusKey?: string) => { flushSync(() => setRows(next)); if (focusKey) controls.current.get(focusKey)?.focus(); }; const move = (id: number, offset: number) => { const index = rows.findIndex((row) => row.id === id); const target = index + offset; if (index < 0 || target < 0 || target >= rows.length) return; const next = [...rows]; [next[index], next[target]] = [next[target], next[index]]; update( next, target === 0 ? `down-${id}` : target === rows.length - 1 ? `up-${id}` : undefined, ); }; const remove = (id: number) => { const index = rows.findIndex((row) => row.id === id); const next = rows.filter((row) => row.id !== id); const neighbor = next[index] ?? next[index - 1]; update(next, neighbor ? `remove-${neighbor.id}` : "add"); }; const add = () => { const id = rows.reduce((max, row) => Math.max(max, row.id), 0) + 1; setRows([...rows, { id, label: `Item ${id}` }]); }; return ( <div className="grid w-full max-w-sm gap-4"> <PresenceList items={rows} getKey={(row) => row.id} aria-label="Reorderable list" > {(row) => ( <span className="flex min-w-0 items-center gap-2 rounded-lg bg-card px-3 py-2 text-sm shadow-[var(--shadow-card)] ring-1 ring-border/70"> <span className="min-w-0 flex-1 truncate">{row.label}</span> <Button ref={control(`up-${row.id}`)} variant="ghost" iconOnly aria-label={`Move ${row.label} up`} isDisabled={rows[0]?.id === row.id} onPress={() => move(row.id, -1)} > <CaretUpIcon aria-hidden="true" /> </Button> <Button ref={control(`down-${row.id}`)} variant="ghost" iconOnly aria-label={`Move ${row.label} down`} isDisabled={rows[rows.length - 1]?.id === row.id} onPress={() => move(row.id, 1)} > <CaretDownIcon aria-hidden="true" /> </Button> <Button ref={control(`remove-${row.id}`)} variant="ghost" iconOnly aria-label={`Remove ${row.label}`} onPress={() => remove(row.id)} > <TrashIcon aria-hidden="true" /> </Button> </span> )} </PresenceList> <div> <Button ref={control("add")} variant="secondary" size="sm" onPress={add} > <PlusIcon aria-hidden="true" /> Add item </Button> </div> </div> );}Show more
API reference
- items: readonly T[]Required
- Items to show in the list.
- getKey: (item: T) => string | numberRequired
- Returns a unique key for an item. Keep the key the same while the item is in the list.
- children: (item: T) => ReactNodeRequired
- Renders the content of each list item. Move focus before you remove a focused item.