Presence List
Animate items as they enter, leave or reorder in lists and tables.
Use it for lists that change while people watch: notifications, a queue, a filtered list, a table whose rows are added, removed or re-sorted. Each change happens where the eye can follow it. Don't use it when the whole list is replaced at once, like the next page of results. Key the list by page instead, or use Content Swap.
Installation
npm i @youj/motionFor an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.
Usage
import { PresenceItem, PresenceList } from '@youj/motion'
export function Notifications({ items }: { items: Notification[] }) {
return (
<PresenceList className="flex flex-col gap-1">
{items.map((item) => (
<PresenceItem key={item.id} className="rounded-lg bg-surface px-3 py-2">
{item.title}
</PresenceItem>
))}
</PresenceList>
)
}- Every
PresenceItemis a direct, keyed child of itsPresenceList. The key is how a row is recognized as the same row after a change, so use a stable id, never the index. aspicks the elements:ul(the default),ol,tbodyordivfor the list, andli(the default),tr,div,articleorsectionfor items.- Items on the first render start settled. Pass
initialto animate them in as well.
Examples
Enter, leave, reorder
- Enter: the item takes its slot and fades down a few pixels, while the items below glide to make room.
- Leave: the item fades where it stands and keeps its space, so column widths hold, then it's removed and the items below glide up.
- Reorder: items glide to their new slots.
Movement is a translate-only FLIP, with no scale, so text, borders and cells never distort. Items are measured against the list, so when the list itself moves, because a toolbar appears above it or a card re-centers, the items don't glide for nothing. Each list is its own layout group, so two lists never fight, and memoized rows still animate.
Reordering
Sort on every render. When a change moves an item, it glides to its new slot, and the items it passes glide the other way. Vote a few times.
Table rows
Table rows rule out the usual tricks: an absolutely positioned <tr> loses table layout, and a row can't be clipped or collapsed by height. Presence List avoids both, and a table needs four rules of its own:
border-separate border-spacing-0, with dividers on the cells.border-collapse, Tailwind's default for tables, paints borders on the table grid, so they'd stay behind while rows move. Aborder-bon the row does nothing in separate mode.- A background on every row, such as
bg-surface, so rows that cross during a re-sort cover each other cleanly. - Fixed column widths, with
table-fixedor column sizing, so removing the widest row doesn't reflow every column mid-animation. - A keyed
tbodyfor whole-page swaps. Key it by page for pagination, so a page change isn't twenty exits and twenty enters.
<table className="w-full table-fixed border-separate border-spacing-0">
<thead>…</thead>
<PresenceList as="tbody">
{rows.map((row) => (
<PresenceItem as="tr" key={row.id} className="bg-surface">
<td className="border-b border-line [tr:last-child>&]:border-b-0">…</td>
</PresenceItem>
))}
</PresenceList>
</table>Don't use it with a virtualizer such as TanStack Virtual, which positions rows itself.
Reference
Propschildren, as, initial… (5)
Props
PresenceList
| Prop | Type | |
|---|---|---|
as | 'div' | 'ol' | 'tbody' | 'ul'The list element. default 'ul' | 'ul' |
initial | booleanAlso animate the items present on first render. default false | false |
childrenRequired | ReactNodePresenceItems, keyed by a stable id. | – |
Other props pass through: extends HTMLAttributes<HTMLElement>. | ||
PresenceItem
| Prop | Type | |
|---|---|---|
as | 'div' | 'section' | 'article' | 'li' | 'tr'The element to render. default 'li' | 'li' |
children | ReactNode | – |
Other props pass through: extends HTMLAttributes<HTMLElement>. | ||
MotionEnter, leave, move · reduced motion
Motion
| Moment | Tokens | ||
|---|---|---|---|
| Enterfade and a 6px drop into the slot | duration.baseease.enter220ms · cubic-bezier(0.16, 1, 0.3, 1) | 220mscubic-bezier(0.16, 1, 0.3, 1) | |
| Leavefade in place, keeping the space | duration.fastease.exit140ms · cubic-bezier(0.4, 0, 1, 1) | 140mscubic-bezier(0.4, 0, 1, 1) | |
| Movemaking room, closing gaps, reordering | spring.smooth0.36s, bounce 0 | 0.36s, bounce 0 |
Reduced motion
Items are added, removed and reordered in one frame, with no layout animation at all.
Reduced motion
Every change applies at once: new items appear in their slot, removed ones are gone, and reordered ones are in their new place. The list is the same as with motion. It just doesn't travel.
Accessibilityfocus, announcements
Accessibility
- The list keeps its semantics: a
ulorolofli, or a realtbodyoftr. Screen readers announce the list and its length, and a table stays navigable by row and column. - A leaving item is
inertfrom its first frame, so it can't take focus or clicks while it fades. - Removing the focused item would drop focus on the page body. When an item is removed with a button inside it, move focus first, to a neighbor's matching button or to the list's own controls. The notifications example does this.
- The list doesn't announce changes. When a change is news, like a notification arriving, announce it from a live region.