View Transitions for List Reordering
Sorting a table, filtering a product list, dragging a task into a new position: in each case items jump to new places, and without animation the user loses track of what went where. The classic fix is FLIP — measure every item's First position, apply the change, measure the Last position, Invert with a transform, then Play the transform back to zero. It works, and it takes a small library or a careful hundred lines of script. A same-document view transition performs the same measurement and animation natively. Give each item a unique name, wrap the DOM update in startViewTransition, and the browser morphs every item from its old box to its new one. This page covers the naming, the styling of moving, entering and leaving items, and the limits for long lists. It belongs to View Transitions for CSS Developers in the CSS-Only Micro-Interactions & Animations guide.
What the browser does for each item
When the transition starts, the browser snapshots every element with a view-transition-name. After the update callback runs, it snapshots them again. For each name present in both captures it creates a ::view-transition-group that animates from the old box's position and size to the new one, with the old and new images crossfading inside. Names present only before are leaving items; names present only after are entering items.
The complete implementation
A sortable, filterable task list. The script only changes the DOM; every visual effect is in CSS.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reordering with view transitions</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 2rem; }
.toolbar { display: flex; gap: 0.5rem; margin-block-end: 1rem; }
.visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.tasks { list-style: none; padding: 0; display: grid; gap: 0.5rem; max-inline-size: 28rem; }
.task {
view-transition-class: task;
display: flex;
justify-content: space-between;
padding: 0.75rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
}
/* Moving items: a firm, short move. */
::view-transition-group(*.task) {
animation-duration: 280ms;
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
}
/* Leaving and entering items: fade and shrink instead of crossfading in place. */
::view-transition-old(*.task):only-child { animation: task-out 180ms ease-in both; }
::view-transition-new(*.task):only-child { animation: task-in 220ms ease-out 60ms both; }
@keyframes task-out { to { opacity: 0; scale: 0.9; } }
@keyframes task-in { from { opacity: 0; scale: 0.9; } }
@media (prefers-reduced-motion: reduce) {
/* Include the :only-child selectors: they are more specific
than the plain ones and would otherwise keep their timings. */
::view-transition-group(*.task),
::view-transition-old(*.task):only-child,
::view-transition-new(*.task):only-child {
animation-duration: 1ms;
animation-delay: 0s;
}
}
</style>
</head>
<body>
<div class="toolbar">
<button type="button" data-sort="due">Sort by due date</button>
<button type="button" data-sort="name">Sort by name</button>
<button type="button" data-filter>Hide done</button>
</div>
<p class="visually-hidden" aria-live="polite" id="status"></p>
<ul class="tasks" id="tasks"></ul>
<script>
const tasks = [
{ id: 't1', name: 'Write release notes', due: 3, done: false },
{ id: 't2', name: 'Fix login bug', due: 1, done: true },
{ id: 't3', name: 'Review pull request', due: 2, done: false },
];
let sortKey = 'due';
let hideDone = false;
function render() {
const list = document.getElementById('tasks');
const shown = tasks
.filter((t) => !(hideDone && t.done))
.sort((a, b) => (a[sortKey] > b[sortKey] ? 1 : -1));
list.replaceChildren(...shown.map((t) => {
const li = document.createElement('li');
li.className = 'task';
li.style.viewTransitionName = `task-${t.id}`; /* unique per item */
li.textContent = t.name;
return li;
}));
document.getElementById('status').textContent =
`${shown.length} tasks, sorted by ${sortKey === 'due' ? 'due date' : 'name'}`;
}
function update(change) {
if (!document.startViewTransition) { change(); render(); return; }
document.startViewTransition(() => { change(); render(); });
}
document.querySelectorAll('[data-sort]').forEach((b) =>
b.addEventListener('click', () => update(() => { sortKey = b.dataset.sort; })));
document.querySelector('[data-filter]').addEventListener('click', () =>
update(() => { hideDone = !hideDone; }));
render();
</script>
</body>
</html>
replaceChildren throws away every list item and creates new ones on each render. It does not matter: pairing is by name, so each task still animates from its old row to its new one. That is what makes the technique framework-friendly — whatever a renderer does to the DOM, the transition only sees names and boxes.
The key technique: one unique name per item, one class for all
The rule that makes list transitions work is that every item needs a distinct view-transition-name, derived from stable data such as a record ID. Deriving it from the index (task-0, task-1) is a subtle mistake: after sorting, task-0 is a different task, so the browser morphs whatever is now first from the old first row's box, which looks like items swapping content rather than moving.
The class does the opposite job: it is shared, so one rule styles every item's group. View Transition Types and Classes explains the *.task selector and the :only-child trick used for entering and leaving items.
Handling long lists
Each named element is captured as a separate image and animated as a separate group. For a list of twenty, that is unnoticeable. For a list of five hundred, capturing and compositing every row costs real time at the start of the transition, and most of those rows are off screen where nobody sees them move.
The practical answer is to name only what is visible. Before starting the transition, give names to items that intersect the viewport (an IntersectionObserver already tracking visibility makes this cheap) and clear names from the rest. Off-screen items then change instantly, which no one notices, while visible items animate. Items moving from off screen into view simply fade in, which is a reasonable approximation.
Two more habits keep list transitions quick. Avoid naming elements inside each item as well as the item itself unless they genuinely need independent motion, since every name doubles the capture count. And keep the transition short — under 300 milliseconds for moves — so rapid successive sorts do not queue behind each other. Starting a new transition while one is running skips the old one to its end state, so fast clicking stays responsive.
Variation: drag-to-reorder
For drag and drop, the pointer already provides continuous motion for the dragged item, so only the other items need to animate as they make room. Remove the dragged item's name while it is being dragged — otherwise the browser would try to morph it from its pre-drag position — and start a transition each time the drop target changes. Each transition animates the neighbours sliding up or down by one row. Because a new transition skips the previous one to its end, a fast drag across many rows stays responsive: neighbours jump to their latest positions rather than queueing a backlog of slides. On drop, restore the dragged item's name so the next sort includes it.
Keyboard reordering deserves the same animation. A "move up" and "move down" button pair, or arrow keys on a focused item, can call the same update function, giving keyboard users the same visual confirmation that the item moved and where it landed.
Accessibility
The transition is visual only. The accessibility tree reflects the new order the moment the update callback runs, and screen readers do not perceive the motion. That makes an explicit announcement important: the aria-live="polite" status element in the implementation states the new count and sort order after each change. Keyboard focus also deserves care — if the focused item moves, focus stays on it in the DOM, but if your renderer replaces nodes, as replaceChildren does, focus is lost. Restore it to the equivalent new node after rendering. For users who prefer reduced motion, the implementation shortens every animation to a millisecond, which still lets the browser apply the final state cleanly.
Browser support
Same-document view transitions and view-transition-name are supported in Chrome and Edge 111+, Firefox 144+ and Safari 18+. view-transition-class arrived after the core API; browsers that support transitions but not classes run the default crossfade and morph for each item, which still conveys the reorder. Browsers without view transitions take the if (!document.startViewTransition) path and update instantly. prefers-reduced-motion is supported in Chrome 74+, Edge 79+, Firefox 63+ and Safari 10.1+.
FAQ
How do view transitions animate a sorted list?
Give every item a unique view-transition-name. When the list is re-rendered inside document.startViewTransition(), the browser captures each item's old and new position and animates it between them, the same way the FLIP technique does by hand.
Do I need to keep the same DOM nodes when reordering? No. Items are paired by name, not by node identity. A framework can throw away the whole list and render a new one; as long as each item's name is the same before and after, it animates from its old position to its new one.
How many items can a list transition handle? Each named item is captured as its own image and animated as its own group, so cost grows with the count. Dozens of items are fine; for hundreds, name only the items in or near the viewport and let the rest change instantly.
Does a reordering animation affect screen reader users? The transition is purely visual; the accessibility tree updates immediately with the new order. Announce the change, for example with a live region stating the new sort order, because the visual motion is not conveyed to assistive technology.
Related
- View Transitions for CSS Developers — the parent guide.
- View Transition Types and Classes — styling many items with one rule.
- view-transition-name and Shared Elements — the uniqueness constraint.
- Staggered List Animations With Custom Properties — entrance motion for lists.
Related articles
More pages in the same section.