Sortable List
Reorder a list by dragging or using the keyboard, with each move announced.
import { SortableList, SortableListHandle, SortableListItem } from '@youj/react'
Installation & usage
npm i @youj/reactFor 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.
Use a sortable list when the order itself is the data: priorities, a playlist, the steps of a pipeline, the columns of a table. The list owns dragging, focus and announcements; you render what's inside each row. When items also move between groups, use a Kanban board. When people sort by a field rather than by hand, use a data table.
Each item has a value, and the list's value is those values in order. Control it with value and onValueChange, and render the items in that order.
import { useState } from 'react'
import { SortableList, SortableListHandle, SortableListItem } from '@youj/react'
export function Priorities({ items }: { items: { id: string; title: string }[] }) {
const [order, setOrder] = useState(items.map((i) => i.id))
const byId = new Map(items.map((i) => [i.id, i]))
return (
<SortableList aria-label="Priorities" value={order} onValueChange={setOrder}>
{order.map((id) => (
<SortableListItem key={id} value={id} textValue={byId.get(id)!.title}>
<SortableListHandle />
{byId.get(id)!.title}
</SortableListItem>
))}
</SortableList>
)
}onValueChange fires once, when a row is dropped somewhere new. It doesn't fire on each step of a drag or when a move is canceled, so it's a safe place to save. Without value, the list keeps its own order, starting from defaultValue or the children's order.
Give each item a textValue. Announcements use it ("Picked up Audit log export, position 3 of 5."), and so does the handle's name ("Reorder Audit log export"). Without one, announcements read the row's text.
Description
Q4 priorities as a ranked list: each row has a drag handle, a rank counter and a team badge.
A playlist where the whole row is the handle.
With asHandle, the row is its own grip: it takes focus, and a drag can start anywhere on it except its buttons, links and fields. Use it for short rows with nothing else to press. Rows with actions keep a separate SortableListHandle, so a press on an action never starts a drag.
On touch screens, a handle doesn't scroll the page, so a list whose rows are all handles can't be scrolled by swiping over it. Prefer a grip in long lists.
Code
sortable-list-demo.tsx, the Demo story. The component itself comes from the package.
Reference
Propsvalue, defaultValue, onValueChange… (10)
Props
SortableList
| Prop | Type | |
|---|---|---|
value | string[]The items' values in order (controlled). | – |
defaultValue | string[]The starting order when uncontrolled. Default: the children's order. | – |
onValueChange | (value: string[]) => voidCalled with the new order when a row is dropped somewhere new. | – |
disabled | booleanTurn reordering off; the rows stay as a plain list. default false | false |
aria-label | stringNames the list for assistive tech. | – |
children | ReactNode | – |
Other props pass through: extends ComponentProps<'ul'>. | ||
SortableListItem
| Prop | Type | |
|---|---|---|
valueRequired | stringThis item's id in the list's value. | – |
textValue | stringWhat announcements call this item. Default: its text. | – |
asHandle | booleanThe whole row is the handle: it takes focus and starts a drag from anywhere on it. default false | false |
Other props pass through: extends ComponentProps<'li'>. | ||
SortableListHandle
| Prop | Type | |
|---|---|---|
aria-label | stringNames the handle. Default: "Reorder" plus the item's | – |
Other props pass through: extends ComponentProps<'button'>. | ||
MotionNeighbors make room, drop, grip… · reduced motion
Motion
| Moment | Tokens | ||
|---|---|---|---|
| Neighbors make roomrows glide aside as the dragged row passes them, and on each keyboard step | spring.smooth0.36s, bounce 0 | 0.36s, bounce 0 | |
| Dropthe row settles into its slot from wherever the pointer let go | spring.smooth0.36s, bounce 0 | 0.36s, bounce 0 | |
| Gripits dots fuse into bars while the row is held | spring.snappy0.2s, bounce 0 | 0.2s, bounce 0 | |
| Liftthe row scales to 1.015 and gains a shadow and a ring-colored border | duration.fastease.standard140ms · cubic-bezier(0.2, 0, 0, 1) | 140mscubic-bezier(0.2, 0, 0, 1) |
Reduced motion
Rows jump to their new places and the lifted row doesn't scale. A pointer drag still follows the pointer.
The dragged row stays in the list (a drag on the row itself with layout on its neighbors, the same motion layer the Kanban board uses, not motion's Reorder), so screen readers and the DOM order always agree with what's on screen.
Reduced motion
Rows move to their new places at once, keyboard moves included, and the lifted row doesn't scale. Dragging with a pointer still follows the pointer, because that movement is the person's own.
AccessibilityKeyboard, ARIA, focus
Accessibility
- The list is a
listoflistitems. Name it witharia-labeloraria-labelledby. - The handles are a single tab stop.
↑and↓move focus between them, andHomeandEndjump to the first and last. SpaceorEnterpicks the focused row up. While it's lifted,↑and↓move it one place,HomeandEndmove it to either end,SpaceorEnterdrops it, andEscapeputs it back. Moving focus away also puts it back.- A handle is a
<button>witharia-pressedwhile its row is lifted, and describes these keys througharia-describedby. - A polite live region announces each pick-up, move, drop and cancel with the row's position.
Escapealso cancels a pointer drag: the row settles back into its old slot.