Skip to content
Component installs need the registry setup
VegaStack Design

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
stable
Since
0.4.0
Accessibility pattern
keyboard-first drag alternative

Last updated

Lead
Qualified
Proposal
WonLocked

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-list

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/sortable-list

Run 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

BehaviourWhere it lives
The persisted orderHost — onReorder is a request; the app owns storage
SelectionNot here, deliberately: reordering + multi-select on one surface makes drag intent ambiguous — DataList owns selection
Cross-container movesBoard
Virtualized orderingdata-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.

Overview
Members
Billing
Audit log

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.

Lead
Qualified
Proposal
Won

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.

UnitBuilt-in
Colour
Material
WeightBuilt-in

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.

Landscape
Portrait 1
Portrait 2
Portrait 3
Landscape 2

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.

image 1
image 2
image 3
40%
x
image 4
Upload failed.

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.

Verified domains
SSO providers
Webhooks

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

PropTypeDefaultDescription
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-labelstring"Sortable list"Accessible name for the list.
classNamestring—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.
disabledbooleanfalseDisable all reordering (rows render without handles, remove buttons or menus; renderActions still renders).
flushbooleanfalseList 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.
footerReact.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.
headerReact.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.
lockedReasonstring—Why locked rows cannot move — the accessible description of a locked row ("Built-in values can't move").
menuItems((item: T) => RowAction[])—Deprecated.
minTileWidthnumber136Grid 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.
refReact.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

AttributeValues
data-drag-itemmirrors a prop or state value
data-drag-pendingmirrors a prop or state value
data-drag-surface""
data-dragging""
data-drop-containermirrors a prop or state value
data-drop-edgemirrors a prop or state value
data-drop-overmirrors a prop or state value
data-handlemirrors a prop or state value
data-layoutmirrors 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

PropTypeDefaultDescription
id*string—Stable id — the identity onReorder moves.
disabledbooleanfalseLock 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.
labelstring—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 ×; lockedReason is its description.
KeyAction
Space / EnterToggle 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).
EscapeEnd move mode.
ContractStates tested
Behaviouridle, dragging, move-mode, pending, rejected, disabled, item-disabled, locked, grid, touch-drag, removable, row-menu, bare-tile, columns
Accessibilitykeyboard, labeled, status-announcement, semantic-html
Visualdefault, dragging, drop-edge, pending, disabled, locked, grid

Do / Don't

Do
Keep the list controlled and refuse bad orders by rejecting the onReorder promise — the snap-back and announcement are built in.
Don't
Add selection checkboxes to sortable rows — ambiguous drag intent; compose DataList when rows need selecting.

On this page