Reorderable Table
Drag rows to reorder them, built on @dnd-kit/sortable rather than any table-specific drag logic. Column headers stay put — for drag-to-reorder columns, see Resizable / Reorderable Columns.
npx shadcn add https://shad-table.dev/r/reorderable-table.json| Title | Priority | Status | |
|---|---|---|---|
| Design landing page | High | In progress | |
| Set up CI pipeline | Medium | Todo | |
| Write onboarding docs | Low | Todo | |
| Fix pagination bug | High | In progress | |
| Add dark mode toggle | Medium | Done | |
| Audit accessibility | Low | Todo |
How it works
1.Row order is just a sortable list of ids
@dnd-kit/sortable doesn't know anything about tables — it only knows about an ordered array of ids and a strategy for laying them out (vertical, in this case). Row order lives directly in the data array itself; dataIds is just that array's ids, recomputed whenever data changes.
const dataIds = useMemo(() => data.map((row) => row.id), [data])
2.A drag handle, not the whole row, starts the drag
useSortable gives back attributes/listeners that must land on the actual draggable element. Spreading them only onto the GripVertical button — not the row itself — keeps clicking a cell's text or a header's sort control working normally; only the handle initiates a drag.
<buttontype="button"{...attributes}{...listeners}aria-label="Reorder row"><GripVertical className="h-3.5 w-3.5" /></button>
3.Dragging updates real state, not just visual position
onDragEnd computes the old and new index from the dragged id and calls arrayMove — the same helper dnd-kit ships for this exact case — then calls the parent's onDataChange so the reorder is visible to whoever owns the data, not just the table.
function handleRowDragEnd(event: DragEndEvent) {const { active, over } = eventif (!over || active.id === over.id) returnconst oldIndex = dataIds.indexOf(active.id as string)const newIndex = dataIds.indexOf(over.id as string)onDataChange(arrayMove(data, oldIndex, newIndex))}
4.DndContext wraps the whole Table, not its header or body
restrictToVerticalAxis keeps drags from drifting sideways. DndContext has to wrap the entire Table from outside — it renders its own hidden accessibility nodes, and a <table> can only contain <thead>/<tbody>, so nesting a DndContext directly inside one breaks the HTML.
<DndContext modifiers={[restrictToVerticalAxis]} onDragEnd={handleRowDragEnd}><Table><TableHeader>{/* plain, non-draggable headers */}</TableHeader><TableBody>{/* draggable rows */}</TableBody></Table></DndContext>
5.getRowId keeps identity stable across reorders
TanStack Table defaults to using array index as a row's id, which breaks the moment you reorder the underlying array — row 2 becomes row 3 and loses its identity. getRowId: (row) => row.id pins each row's identity to its own data, so sorting, selection, and the drag state all keep tracking the right row after a reorder.
const table = useReactTable({data,columns,getRowId: (row) => row.id,// ...})