ShadTable
DiscordGitHub

ShadTable

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

Resizable / Reorderable Columns

Drag a header's grip to reorder columns, drag its right edge to resize — the resulting layout is saved to localStorage and restored on your next visit.

npx shadcn add https://shadcn-table-library.vercel.app/r/resizable-reorderable-columns-table.json

Drag a header's grip to reorder, drag its right edge to resize.

Name
Email
Role
Status
Ava Thompsonava.t@example.comAdminActive
Liam Chenliam.chen@example.comEditorActive
Sofia Patelsofia.p@example.comViewerInactive
Noah Garcianoah.g@example.comEditorActive
Mia Johnsonmia.j@example.comAdminPending

How it works

1.Resizing is a built-in TanStack Table feature, not custom drag math

enableColumnResizing plus columnResizeMode: 'onChange' turns on the feature; header.getResizeHandler() returns a ready-made onMouseDown/onTouchStart handler that TanStack wires up to compute the new width itself from the pointer delta. This component only renders the thin edge strip and reads header.getSize() back — no manual pointer-position math.

src/components/resizable-reorder/data-table.tsx
const table = useReactTable({
data,
columns,
state: { columnOrder, columnSizing },
onColumnSizingChange: setColumnSizing,
columnResizeMode: 'onChange',
enableColumnResizing: true,
getCoreRowModel: getCoreRowModel(),
})

2.Column reordering reuses the Reorderable Table pattern exactly

Same horizontal SortableContext + columnOrder state as the earlier Reorderable Table, right down to wrapping the whole <Table> in DndContext from the outside rather than nesting it inside <TableHeader> — DndContext renders hidden accessibility nodes that aren't valid direct children of <table>, which is what broke that example the first time around.

src/components/resizable-reorder/data-table.tsx
<DndContext modifiers={[restrictToHorizontalAxis]} onDragEnd={handleDragEnd}>
<Table style={{ width: table.getTotalSize() }}>
<TableHeader>{/* ... */}</TableHeader>
<TableBody>{/* ... */}</TableBody>
</Table>
</DndContext>

3.The resize handle and the drag handle are different elements

Each header has two separate interactive regions: the GripVertical button (spread with dnd-kit's attributes/listeners) starts a column-reorder drag, and a thin absolutely-positioned strip on the header's right edge (bound to header.getResizeHandler()) starts a resize. Because they're different DOM nodes, grabbing one can never accidentally trigger the other.

src/components/resizable-reorder/data-table.tsx
<button {...attributes} {...listeners} aria-label="Reorder column">
<GripVertical className="h-3.5 w-3.5" />
</button>
{/* ...elsewhere in the same header: */}
<div onMouseDown={header.getResizeHandler()} onTouchStart={header.getResizeHandler()} />

4.Layout persists to localStorage, but only after the client hydrates

columnOrder and columnSizing both start at the same defaults on the server and the first client render, so SSR output matches hydration exactly. A useEffect that only runs in the browser then reads localStorage and swaps in a saved layout — a separate save-effect is guarded by an isHydrated ref so it can't fire and overwrite storage with the defaults before that load has actually happened.

src/components/resizable-reorder/data-table.tsx
useEffect(() => {
const saved = loadLayout()
if (saved.columnOrder) setColumnOrder(saved.columnOrder)
if (saved.columnSizing) setColumnSizing(saved.columnSizing)
isHydrated.current = true
}, [])
useEffect(() => {
if (!isHydrated.current) return
window.localStorage.setItem(STORAGE_KEY, JSON.stringify({ columnOrder, columnSizing }))
}, [columnOrder, columnSizing])

5.Reset layout clears storage and both pieces of state together

resetLayout wipes the localStorage key and sets columnOrder/columnSizing back to their defaults in the same function call, so the on-screen table and its persisted copy in localStorage are never briefly out of sync with each other.

src/components/resizable-reorder/data-table.tsx
function resetLayout() {
window.localStorage.removeItem(STORAGE_KEY)
setColumnOrder(columns.map((c) => c.id as string))
setColumnSizing({})
}