ShadTable
DiscordGitHub

ShadTable

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

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.json
Title
Priority
Status
Design landing pageHighIn progress
Set up CI pipelineMediumTodo
Write onboarding docsLowTodo
Fix pagination bugHighIn progress
Add dark mode toggleMediumDone
Audit accessibilityLowTodo

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.

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

src/components/reorder/data-table.tsx
<button
type="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.

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

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

src/components/reorder/data-table.tsx
const table = useReactTable({
data,
columns,
getRowId: (row) => row.id,
// ...
})