Sortable List
Reorderable rows or tiles with pointer drag, touch long-press, keyboard move mode, a remove button, locked rows and server-refusable moves.
- Status
- Since
0.4.0- Accessibility pattern
- keyboard-first drag alternative
Last updated
Drag the handle, long-press a row on a touch screen, or press Space on the handle for keyboard move mode — every path reaches every order.
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Sortable List before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify sortable-listInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/sortable-listRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/button, @vegastack/data-table-parts, @vegastack/drag-item, @vegastack/dropdown-menu, @vegastack/item, @vegastack/tile-overlay, @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.
Each row has a drag handle, its content, optional inline actions
(renderActions), and — with onRemove — a small × named Remove {label}. A host
that needs more than remove passes getItemActions for a ⋯ menu named
Actions for {label} (actionsLabel renames it). A row with disabled: true is
locked: it keeps its place, shows no ×, and other rows move past it.
layout="grid" wraps the same list into image tiles. List rows are as dense as a
DataList's — compact padding and a subtle wash on hover — and the
× and ⋯ show on row hover or focus, always on a touch screen.
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
With inline rename
When a row's content is a text field, drag starts only from the handle, so typing
(Space included) never lifts the row. renderActions puts a delete button at the
row's end.
Remove
onRemove(item) puts a small ghost × in each row's trailing corner, named Remove {label}. It
shows on hover or focus within the row, and always on a coarse pointer (a touch screen), where
there is no hover. Focus moves to the row that takes the removed row's place. A locked row shows
no ×. Controls a row carries, like this "Required" checkbox from renderActions, drag with it:
the drag image is a copy of the row alone.
footer is the list's one row that is not an item — an "Add a value" field and its Add button.
It sits on the rows' grid: its content starts after the handle gutter and stops before the ×
gutter, so the field is exactly as wide as the values above it. It never drags, and it renders
when the list is empty too.
header is the same kind of row above the list, full width with no gutters, for an add field
whose new value is inserted first. flush drops the rows' hover wash and the side padding kept
for it, so rows whose content is its own bordered control (an editable Input) run edge to edge.
align="start" pins the handle, the × and the ⋯ to a row's first line instead of its middle, for
tall rows such as a card whose title line sits over an expandable body.
With row actions
When a row needs more than remove, getItemActions(item) returns its own RowActions — rename,
delete — in a ⋯ menu: one menu per row, named Actions for {label} (Board's cards take the same
accessor). No actions, no menu. A disabled action stays reachable and reads its disabledReason.
They stay available on a locked row, and an action that removes its row leaves focus on the row
that took its place. menuItems is the deprecated name of the same accessor.
Locked rows
Set disabled: true on an item to lock it. Its handle becomes a spacer of the
same size, so the handles stay aligned, and it shows no ×. Its getItemActions
menu stays, and lockedReason is the row's accessible description. Other rows
still move past a locked row: the lock fixes the row, not the positions around it.
Image grid
layout="grid" wraps the list into tiles at least --spacing(28) wide, as many
per row as fit — or at most columns a row (2–6), fewer as the container narrows. The
handle and the actions sit in the tile's top-left and top-right overlay slots. A drop
lands left or right of a tile, and in keyboard move mode ←/→
step one tile while ↑/↓ step a whole row. The column count
is measured, so the row step is right at any width.
Attachments in a grid
tile="bare" drops the tile's own border and padding, so an Attachment inside is the tile's
one frame — no card inside a card. The attachment keeps its own states: the progress bar while
uploading, the dashed border when idle, the destructive border and message on error.
columns={4} is a cap, not a fixed count: four tiles a row on a wide container, three and then two
as it narrows (never below 8.5rem a tile) — the same grid AttachmentGroup layout="tiles" uses.
minTileWidth raises that floor for a wider tile: a card with a name and a meta line passes 240,
one card a row on a phone and four on a desktop. columns="cards" lays the tiles on DataList's
dense card grid instead — one a row, two from a 32rem list, three from 48rem and four from 72rem,
12px apart — so a sortable row of MediaCards lines up with the DataList grids around it.
The drag handle ⠿ sits in the tile's top-left overlay slot and the × in its top-right one, inset
from the corner on a blurred scrim; both show on hover or focus within the tile, always on a touch
screen.
Give each Attachment a file and wrap the list in AttachmentPreview: a click or tap on a tile —
or Enter or Space on it — opens the FileViewer, paging through every tile. That coexists with
reordering because a drag starts only from the handle (or a touch long-press, which never ends in
a click), and a click on the handle or the × never opens the viewer.
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.
Touch
On a touch screen, press and hold a row or tile for a quarter second without moving — the same
long-press Board uses — and it lifts; drag it, and the drop line shows where it will land. A
swipe that moves before the hold completes scrolls the page as usual, the page does not scroll
under a lifted row, and the long-press menu is suppressed. The list scrolls when the finger nears
its scroll container's edge.
Where the grip would sit over the content on a phone (a card's type icon), pass handle="fine":
the grip shows only on a mouse or pen, and on a touch screen the long-press is the way to drag.
The handle stays in the tab order and reappears while it has keyboard focus, so Space-to-move
still works with a keyboard.
Drag handle
handle="auto" shows the grip only while two or more rows can move; with one row the handle's
column stays reserved but empty, so a row's content never moves when a second row arrives (a
one-key SortBuilder). With flush, rows carry no hover wash and no side padding — the shape a
list takes inside a popover, where the popover is the one surface.
Only the handle starts a pointer drag — the rest of the row stays free for text selection and the controls inside it. That holds even when the handle mounts after its row, such as a row that was disabled and becomes editable: the row's drag source is rebuilt around the handle as it is now.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
items* | readonly T[] | — | 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: T) => React.ReactNode | — | Render a row's content (everything except the handle and actions). |
actionsLabel | ((label: string) => string) | (label) => `Actions for ${label}` | Accessible name of a row's menu trigger, from the row's label. |
align | "center" | "start" | "center" | Where the handle and the row's actions sit on a list row: center on its middle, start on
its first line, for rows taller than one line (a title row over an expandable body). The ×
keeps its centre on the handle's. |
aria-label | string | "Sortable list" | Accessible name for the list. |
className | string | — | Extra classes for the list root. |
columns | "cards" | 2 | 3 | 4 | 5 | 6 | — | Grid only: the most tiles a row holds — fewer as the container narrows, never
below 8.5rem a tile (4 is four on a desktop, three on a tablet, two on a
phone). Without it the grid auto-fills tracks at least --spacing(28) wide.
"cards" is DataList's dense grid of default-size cards, column for column: one a
row, two from a 32rem list, three from 48rem and four from 72rem, 12px apart — so
a sortable row of MediaCards lines up with the DataList grids beside it. |
disabled | boolean | false | Disable all reordering (rows render without handles, remove buttons or menus;
renderActions still renders). |
flush | boolean | false | List rows without the hover wash and the side padding reserved for it, so a row's content
(an editable Input) runs the list's full width between the handle and the ×. For rows
whose content is its own bordered control; plain-text rows keep the wash. |
footer | React.ReactNode | — | A trailing row under the list (layout="list" only) laid on the rows' own grid: its content
starts where a row's content starts (after the handle gutter) and ends where it ends (before
the × gutter), so an "Add a value" field lines up with the values above it. It is not an item:
it never drags, and it renders when items is empty too. Controls from renderActions have
no fixed width, so the footer does not reserve room for them. |
getItemActions | ((item: T) => RowAction[]) | — | A row's own actions (rename, delete) in a ⋯ menu — the escape hatch for a host
that needs more than onRemove. One menu per row, as Board's getItemActions;
on a locked row it stays available. No actions, no menu. |
handle | "always" | "auto" | "fine" | "always" | When the drag handle (⠿) shows. always: on every pointer. fine: only on a mouse or pen —
on a touch screen there is no grip (it would sit over the content, a card's type icon) and a
long-press on the row or tile still lifts it. The handle stays in the tab order either way:
hidden visually on touch, it reappears while it has keyboard focus, so Space-to-move works
with a keyboard on any device. auto: like always, but only while there is something to
reorder — with fewer than two movable rows there is no handle column at all, so a one-key
SortBuilder or a one-level GroupByPicker starts at the menu's inset like every other row
(MK 2026-10-30, list-toolbar T15); the column appears with the second movable row. |
header | React.ReactNode | — | A leading row above the list (layout="list" only), full width: no handle or × gutters, so
an "Add a value" field and its Add button span the rows' whole width. Put the add field here
when a new item is inserted first. It is not an item: it never drags, and it renders when
items is empty too. |
layout | "grid" | "list" | "list" | list stacks rows. grid wraps them into tiles with the handle and actions
overlaid on the tile's top corners; drops read left/right of a tile, and ↑/↓
in move mode step a whole row. |
lockedReason | string | — | Why locked rows cannot move — the accessible description of a locked row ("Built-in values can't move"). |
menuItems | ((item: T) => RowAction[]) | — | Deprecated. |
minTileWidth | number | 136 | Grid with numeric columns only: the narrowest a tile goes before the row holds one fewer, in px
(a card with a name and a meta line needs more than a thumbnail's 8.5rem: 240 holds one
card a row on a phone and four on a desktop). |
onRemove | ((item: T) => void) | — | Remove a row. Each unlocked row gets a small × button ("Remove {label}") in its trailing corner, shown on hover or focus within the row and always on a coarse pointer. Focus moves to the row that takes the removed row's place. |
ref | React.Ref<HTMLDivElement> | — | Ref forwarded to the list root (data-slot="sortable-list"). |
removeLabel | ((label: string) => string) | (label) => `Remove ${label}` | Accessible name of a row's remove button, from the row's label. |
renderActions | ((item: T) => React.ReactNode) | — | Inline actions rendered before the row's remove button or menu — a rename
button, a visibility toggle. Rendered on locked rows too, and on every row
when the whole list is disabled. |
tile | "bare" | "outline" | "outline" | Grid only: outline frames each tile; bare drops the tile's border and
padding so its content — an Attachment — is the tile's one frame. |
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-drag-surface | "" |
data-dragging | "" |
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-handle | mirrors a prop or state value |
data-layout | mirrors a prop or state value |
data-locked | "" |
data-slot | "sortable-list" | "sortable-list-actions" | "sortable-list-footer" | "sortable-list-handle" | "sortable-list-handle-spacer" | "sortable-list-header" | "sortable-list-item" | "sortable-list-remove" |
Item
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | Stable id — the identity onReorder moves. |
disabled | boolean | false | Lock this row in place: its handle becomes a same-size spacer and it shows no
remove button, while its getItemActions menu and renderActions stay. Other
rows can still move past it. |
label | string | — | Accessible name for the row's handle and actions ("Reorder {label}", "Remove {label}", "Actions for {label}"). Falls back to the id. |
Accessibility
- Every reorder is reachable three ways: pointer drag on the handle, a touch long-press on the row, and the keyboard move mode (Space lifts, arrows commit one announced step at a time, Escape ends).
- Each step announces through a polite live region ("Moved to position 2 of 3"); a refused move announces "Move rejected — position restored".
- Handles, remove buttons and menu triggers are named per row ("Reorder Alpha", "Remove Alpha", "Actions for Alpha"); drop position shows as a 2px indicator line, and a lifted row dims. The × is always shown on a coarse pointer.
- A locked row has no handle, only a hidden spacer, and no ×;
lockedReasonis its description.
| Key | Action |
|---|---|
| Space / Enter | Toggle move mode on the focused handle. |
| ↑ / ↓ | Commit a one-step move (in move mode). In a grid, move one row. |
| ← / → | In a grid, move one tile (in move mode). |
| Escape | End move mode. |
| Contract | States tested |
|---|---|
| Behaviour | idle, dragging, move-mode, pending, rejected, disabled, item-disabled, locked, grid, touch-drag, removable, row-menu, bare-tile, columns |
| Accessibility | keyboard, labeled, status-announcement, semantic-html |
| Visual | default, dragging, drop-edge, pending, disabled, locked, grid |