Reorderable Table
Drag rows to reorder them, and drag column headers to reorder columns, both built on @dnd-kit/sortable rather than any table-specific drag logic.
npx shadcn add https://shadcn-table-library.vercel.app/r/reorderable-table.jsonTitle | 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.Rows and columns are both just sortable lists
@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 for rows, horizontal for column headers). Row order lives in the data array itself; column order lives in a separate columnOrder array TanStack Table reads through its own state.
const [columnOrder, setColumnOrder] = useState<string[]>(() =>columns.map((c) => c.id as string),)const dataIds = useMemo(() => data.map((row) => row.id), [data])
2.A drag handle, not the whole cell, 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 or header) keeps clicking a column header to sort, or clicking a cell's text, 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. Rows call the parent's onDataChange (so reordering is visible to whoever owns the data); columns call the table's own onColumnOrderChange.
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.Two independent DndContexts, one per axis
Rows drag vertically and columns drag horizontally, so each gets its own DndContext with a restrictToVerticalAxis or restrictToHorizontalAxis modifier — dragging a row handle can never accidentally get picked up as a column reorder, and vice versa. Both wrap the whole Table from outside, not its header or body individually — DndContext 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={[restrictToHorizontalAxis]} onDragEnd={handleColumnDragEnd}><DndContext modifiers={[restrictToVerticalAxis]} onDragEnd={handleRowDragEnd}><Table><TableHeader>{/* ... */}</TableHeader><TableBody>{/* ... */}</TableBody></Table></DndContext></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,// ...})