Ready reorderable lists, grids and nested trees: menu builders, category order, onboarding steps, dashboard tiles, territory trees. Every item has a drag handle and a Move menu (up, down, top, bottom, and for trees indent, outdent and move into…), so nothing requires dragging. Trees pick the depth from the horizontal drag offset and enforce a max depth and your own drop rules.
Header navigation
Up to 3 levels · drag sideways to nestInstallation
The whole kit as one package — get updates by bumping the version. Add the styles to globals.css: @import "@vamos-ui/react/styles.css";
Usage
import { SortableList, SortableGrid, SortableTree, moveTreeNode } from "@vamos-ui/react/sortable";
<SortableList aria-label="Steps" items={steps} onReorder={setSteps} renderItem={(s) => s.title} />
<SortableTree
aria-label="Main menu"
nodes={menu}
maxDepth={3}
canDrop={(c) => (c.parentId === "status" ? "Links can't have sub-items" : true)}
onMove={(e) => setMenu((m) => moveTreeNode(m, e.id, e.parentId, e.index))}
/>Trees use the Tree view pattern: role="tree", one tab stop, ↑ / ↓ move between rows, → opens or enters, ← closes or goes to the parent, Home / End, * opens siblings, type-ahead by first letter.
Moving from the keyboard: Space or Enter lifts the focused row, ↑ / ↓ move it, ← / → change its level, Space drops, Esc cancels. Without lifting: Alt + ↑ / ↓ moves among siblings, Alt + → indents, Alt + ← outdents (mirrored in RTL), Shift+F10 opens the Move menu.
Announcements say where the row will land ("About us will move into Services, position 3.") and the result after a drop or menu move. Focus stays on the moved row.
The dragged clone is an opaque popover without blur, so glass and solid render the same. Reduced motion turns off the shift and drop animations.
Examples
Vertical list
HR onboarding steps in one inset box. Required steps are fixed: no handle, no Move menu, and they keep their slot.
Onboarding checklist — Engineering
- 1Sign employment contractPeople team · Before day 1Fixed
- 2Laptop and accounts readyIT · Day 1
- 3Meet your onboarding buddyManager · Day 1
- 4Security awareness trainingIT · Week 1
- 5Set 30/60/90-day goalsManager · Week 2
- 6Probation check-inManager · Month 3Fixed
Grid
CRM dashboard tiles. The whole tile is the drag activator and arrow keys move it in two dimensions.
- Monthly recurring revenue$128K▲ 6.2% vs last month
- Open pipeline$1.2M▲ 11% vs last month
- Win rate28.4%▼ 1.3% vs last month
- Deals closing this month23▲ 21% vs last month
- Avg. sales cycle (days)41▼ 8% vs last month
- Customer NPS52▲ 4% vs last month
Compact tree
Shop categories at size sm with a 20px indent and two levels. Only Brewing equipment starts expanded; a collapsed parent still accepts children.
Rules and async saving
PRM territories: canDrop keeps regions at the top and territories under a region. onMove returns a promise, so the move shows at once and rolls back with a Retry toast if the server rejects it.
API
SortableTree
| Prop | Type | Default |
|---|---|---|
nodesThe tree. Rows show label / title / name unless renderItem is set. | N[] (N extends { id, children? }) | — |
onMoveOnce per drop or menu move. Apply it with moveTreeNode(nodes, id, parentId, index). A promise keeps the move optimistic; a rejection rolls it back. | (e: { id, parentId, index, fromParentId, fromIndex }) => void | Promise<void> | — |
getLabelRow text, type-ahead, announcements and the Move menu name. | (node) => string | label / title / name |
maxDepthLevels allowed, counting the moved subtree ("Max depth 3"). | number | — |
canDropA string blocks the drop: red indicator and reason on the dragged clone, disabled menu items with the reason. | (c: { id, parentId, index, depth, fromParentId, fromIndex }) => true | string | — |
disabledFixed rows: not draggable, no Move menu. They still accept children. | (node) => boolean | — |
expanded / defaultExpanded / onExpandedChangeOpen rows. Moving into a parent opens it. | string[] | every parent |
indentpx per level; also the horizontal drag distance that changes one level. | number | 24 |
guidesVertical indent guides. | boolean | true |
sizeRow height 32 / 36 px. | "sm" | "md" | "md" |
renderItemRow content (icon, text, badges). | (node, { depth, isDragging, overlay, expanded, childCount }) => ReactNode | — |
renderActionsTrailing buttons before the Move menu. | (node) => ReactNode | — |
moveMenuMove menu on every row (opened with Shift+F10 from the keyboard). | boolean | true |
emptyShown when nodes is empty. | ReactNode | — |
selected / onSelectSelectable rows: click or Enter selects (aria-selected), Space still picks a row up for a keyboard move. The toggle, row actions and the Move menu keep their own click. | string | null / (node) => void | — |
labelsBuilt-in strings. Dictionary namespace: sortableTree. | Partial<SortableTreeLabels> | — |
aria-label / aria-labelledbyName of the tree. | string | — |
SortableList / SortableGrid
| Prop | Type | Default |
|---|---|---|
items / onReorder / renderItemItems in order, the reordered array on every move, row content. | T[] / (next, e) => void / (item, state) => ReactNode | — |
getId / getLabel / disabledStable id, spoken name, fixed items. | (item) => string / (item) => string / boolean | (item) => boolean | — |
variant / size / orientationSortableList only. | "card" | "inset" | "plain" / "sm" | "md" / "vertical" | "horizontal" | "card" / "md" / "vertical" |
minItemWidthSortableGrid only: auto-fill column width. | string | "10rem" |
moveMenu / renderActions / empty / labelsSee the Sortable page for the full list. | boolean / (item) => ReactNode / ReactNode / Partial<SortableLabels> | — |
Core (React-free)
| Prop | Type | Default |
|---|---|---|
moveTreeNode(nodes, id, parentId, index)Immutable move of a node (and its subtree). | N[] | — |
projectTreeMove(flat, activeId, overId, offsetX, indent, { maxDepth, dir })Where a dragged row would land; depth from the horizontal offset (sign flipped in RTL). | { parentId, depth, index, invalid? } | null | — |
flattenTree / descendantIds / arrayMoveHelpers shared with Data Table, Tree view and Line items. | — | — |