Sortable List
Reorderable rows with pointer drag, keyboard move mode, a lossless row menu, and server-refusable moves — controlled; the host owns the order.
- Status
- Since
0.4.0- Accessibility pattern
- keyboard-first drag alternative
Last updated
Drag the handle, press Space on it for keyboard move mode, or use the row menu — every path reaches every order.
Install
Add Sortable List from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/sortable-listThe same command installs the registry items it composes: @vegastack/drag-item, @vegastack/dropdown-menu, @vegastack/icon-button, @vegastack/item, @vegastack/use-drag-reorder.
Usage
import { SortableList } from "@/components/ui/sortable-list";
<SortableList
aria-label="Pipeline stages"
items={stages}
renderItem={(stage) => <span>{stage.label}</span>}
onReorder={({ id, to }) => moveStage(id, to.index)}
/>;SortableList is the single-list consumer of use-drag-reorder (the system's
one drag-engine seam). It is controlled: items arrive in display order,
onReorder requests a move, and the host applies — or refuses — it.
Scope
| Behaviour | Where it lives |
|---|---|
| The persisted order | Host — onReorder is a request; the app owns storage |
| Selection | Not here, deliberately: reordering + multi-select on one surface makes drag intent ambiguous — DataList owns selection |
| Cross-container moves | Board |
| Virtualized ordering | data-grid territory |
Examples
Server-gated moves
Return a promise from onReorder: the moved row shimmers while the write is in
flight; a rejection announces the snap-back — the host never applied the move,
so the order simply stays.
Moving “Webhooks” is refused by the host: the row shimmers while pending, then snaps back and announces the rejection.
The menu path
Every enabled row carries a "Move …" menu — Move up / Move down / Move to top / Move to bottom — so
the whole ordering is reachable without a pointer and without entering keyboard move mode. It runs
the same onReorder callback as a drag, so the host cannot tell the paths apart.
The drop-edge hairline, the lift dim and the pending shimmer come from the shared drag-item
recipe — the same one Board uses, installed once as its own registry item.
Open a row’s menu to move it without dragging — the same reorder callback runs, so the host cannot tell the paths apart.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
items* | readonly SortableListItem[] | — | Rows in display order — controlled; the host re-orders on onReorder. |
onReorder* | (move: DragReorderMove) => void | Promise<void> | — | Apply a requested move. Return a promise for server-gated ordering — the moved row shows the pending shimmer and a rejection announces + snaps back (the host never applied it). |
renderItem* | (item: SortableListItem) => React.ReactNode | — | Render a row's content (everything except the handle and menu). |
aria-label | string | "Sortable list" | Accessible name for the list. |
className | string | — | Extra classes for the list root. |
disabled | boolean | false | Disable all reordering (rows render without handles or menus). |
ref | React.Ref<HTMLDivElement> | — | Ref forwarded to the list root (data-slot="sortable-list"). |
Data attributes and CSS variables on SortableList
| Attribute | Values |
|---|---|
data-drag-item | mirrors a prop or state value |
data-drag-pending | mirrors a prop or state value |
data-dragging | mirrors a prop or state value |
data-drop-container | mirrors a prop or state value |
data-drop-edge | mirrors a prop or state value |
data-drop-over | mirrors a prop or state value |
data-slot | "sortable-list" | "sortable-list-item" |
Item
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | Stable id — the identity onReorder moves. |
disabled | boolean | false | Exclude this row from reordering (its handle and menu disable). |
label | string | — | Accessible name for the row's handle and menu ("Reorder {label}"). Falls back to the id. |
Accessibility
- Every reorder is reachable three ways: pointer drag on the handle, the keyboard move mode (Space lifts, arrows commit one announced step at a time, Escape ends), and the row's Move menu — the required lossless path on touch and for AT.
- Each step announces through a polite live region ("Moved to position 2 of 3"); a refused move announces "Move rejected — position restored".
- Handles and menu triggers are named per row ("Reorder Alpha", "Move Alpha"); drop position shows as a 2px indicator line, and a lifted row dims — flat by doctrine, never a shadow.
| Key | Action |
|---|---|
| Space / Enter | Toggle move mode on the focused handle. |
| ↑ / ↓ | Commit a one-step move (in move mode). |
| Escape | End move mode. |
| Contract | States tested |
|---|---|
| Behaviour | idle, dragging, move-mode, pending, rejected, disabled, item-disabled |
| Accessibility | keyboard, labeled, status-announcement, semantic-html |
| Visual | default, dragging, drop-edge, pending, disabled |