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.jsonDrag a header's grip to reorder, drag its right edge to resize.
Name | Email | Role | Status |
|---|---|---|---|
| Ava Thompson | ava.t@example.com | Admin | Active |
| Liam Chen | liam.chen@example.com | Editor | Active |
| Sofia Patel | sofia.p@example.com | Viewer | Inactive |
| Noah Garcia | noah.g@example.com | Editor | Active |
| Mia Johnson | mia.j@example.com | Admin | Pending |
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.
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.
<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.
<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.
useEffect(() => {const saved = loadLayout()if (saved.columnOrder) setColumnOrder(saved.columnOrder)if (saved.columnSizing) setColumnSizing(saved.columnSizing)isHydrated.current = true}, [])useEffect(() => {if (!isHydrated.current) returnwindow.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.
function resetLayout() {window.localStorage.removeItem(STORAGE_KEY)setColumnOrder(columns.map((c) => c.id as string))setColumnSizing({})}