ShadTable
DiscordGitHub

ShadTable

A collection of composable table components built on shadcn/ui and TanStack Table.

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
NameRoleStatus
Engineering
Department
Frontend
Team
Ava Thompson
Frontend LeadActive
Liam Chen
Frontend EngineerActive
Backend
Team
Sofia Patel
Backend LeadActive
Noah Garcia
Backend EngineerInactive
Design
Department
Product Design
Team
Mia Johnson
Design LeadActive
Ethan Kim
Product DesignerPending

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.

src/components/tree-reorder/data-table.tsx
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.

src/components/tree-reorder/data-table.tsx
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.

src/components/tree-reorder/data-table.tsx
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) return
const 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.

src/components/tree-reorder/data-table.tsx
<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.

src/components/tree-reorder/data-table.tsx
const table = useReactTable({
data,
columns,
getRowId: (row) => row.id,
getSubRows: (row) => row.children,
getExpandedRowModel: getExpandedRowModel(),
// no getSortedRowModel / getFilteredRowModel / getPaginationRowModel
})