Tree Table — Reorder
The same department/team/employee tree, with rows draggable by a handle — reordering is scoped to siblings within the same parent, so a department can't accidentally get dropped inside a team.
npx shadcn add https://shadcn-table-library.vercel.app/r/tree-table-reorder.json| Name | Role | Status | |
|---|---|---|---|
Engineering | Department | — | |
Frontend | Team | — | |
Ava Thompson | Frontend Lead | Active | |
Liam Chen | Frontend Engineer | Active | |
Backend | Team | — | |
Sofia Patel | Backend Lead | Active | |
Noah Garcia | Backend Engineer | Inactive | |
Design | Department | — | |
Product Design | Team | — | |
Mia Johnson | Design Lead | Active | |
Ethan Kim | Product Designer | Pending |
How it works
1.A flat drag list, built from the tree's own flattened rows
@dnd-kit/sortable only understands a flat, ordered list of ids — it has no idea rows are nested. table.getRowModel().rows is already flattened in document order (that's how the tree renders in the first place), so that same list doubles as the SortableContext's items without any extra bookkeeping.
const rowIds = useMemo(() => table.getRowModel().rows.map((row) => row.id),[table, data, expanded],)
2.Reordering is scoped to siblings, on purpose
Drag-and-drop only knows positions in a flat list, but reparenting a node (moving an employee into a different team) is a much bigger decision than reordering it — this example intentionally only supports the latter. reorderSiblings walks the tree looking for the array that contains both the dragged id and the drop target's id; it only reorders when they're found in the same array.
function reorderSiblings(nodes, activeId, overId) {const oldIndex = nodes.findIndex((n) => n.id === activeId)const newIndex = nodes.findIndex((n) => n.id === overId)if (oldIndex !== -1 && newIndex !== -1) {return { nodes: arrayMove(nodes, oldIndex, newIndex), moved: true }}// ...recurse into each node's children looking for a shared parent}
3.Dragging onto a different branch is a silent no-op
Drag Ava Thompson from Frontend and hover over a row inside Design — reorderSiblings never finds an array containing both ids, so moved stays false and onDataChange is never called. The row animates back to its original position because the underlying data, and therefore its position in rowIds, never changed.
function handleDragEnd(event: DragEndEvent) {const { active, over } = eventif (!over || active.id === over.id) returnconst result = reorderSiblings(data, active.id as string, over.id as string)if (result.moved) onDataChange(result.nodes)}
4.A drag handle starts the drag, not the row itself
useSortable’s attributes/listeners are spread only onto the GripVertical button in the first cell, not the row or the expand toggle — so clicking a row’s expand chevron still just expands it, and only grabbing the handle picks the row up.
<button type="button" {...attributes} {...listeners} aria-label="Reorder row"><GripVertical className="h-3.5 w-3.5" /></button>
5.Search, sort, and pagination are left out here too
Same reasoning as the base Tree Table's pagination: any feature that reorders or hides rows out from under the drag list (sorting, filtering, paginating) would fight with rowIds mid-drag. This example only composes expansion and sibling-scoped reordering, kept deliberately minimal so the drag mechanics stay legible.
const table = useReactTable({data,columns,getRowId: (row) => row.id,getSubRows: (row) => row.children,getExpandedRowModel: getExpandedRowModel(),// no getSortedRowModel / getFilteredRowModel / getPaginationRowModel})