Drag the line between two column headings to set that column's width, in a <table> you already have — with the widths kept where you put them, a keyboard path through the same control, and double-click to fit a column to its contents. Reach for it wherever a table has more columns than the default widths can serve: an admin data grid, a logs or audit-trail view, an orders, invoices or billing-history list, a transactions or ledger table, an inventory or stock grid, a spreadsheet-like editor, a CRM or support-ticket queue, search results with a long title column, an analytics or reporting breakdown, a file and document browser, and any table where one column holds a URL, a path, an error message or an email and every other column is three characters wide. Common asks it answers: "resizable table columns react", "drag to resize table column react", "react resizable column width", "shadcn resizable table columns", "shadcn table column resize", "shadcn data table column resizing", "resizable columns without tanstack table", "tanstack table column resizing alternative", "react-table column resize alternative", "ag-grid just for column widths", "column width not applying", "width on th ignored", "style width on table cell does nothing", "table ignores column width", "table-layout fixed not working", "table-layout fixed does nothing", "colgroup width not working", "col element width ignored", "resizing one column changes all the others", "all columns resize together", "column width snaps back after drag", "column collapses to zero width", "cannot grab column resize handle", "resize handle too thin to click", "drag resize stops after one pixel", "column resize handle loses the pointer", "resize handle also sorts the column", "column resize triggers sort", "slow table while dragging column width", "column resize laggy large table", "accessible column resize", "keyboard resizable table column", "resize column with arrow keys", "aria-valuenow separator resize", "role separator splitter react", "column resize rtl", "persist column widths", "save column widths to localstorage", "restore column widths on reload", "double click to autofit column width", "excel autofit column react", "touch drag resizes scrolls the page instead". Not official shadcn/ui's resizable, which is a thin wrapper over react-resizable-panels: that one splits a layout into panels with percentage sizes and a flex container, and cannot be used across a table's <th> elements at all, because a row's children have to be cells. This is the table case, with no dependency added. The reason a hand-rolled one does not hold its widths is measurable, and it is not the thing everyone reaches for. In a 600px scroll container, asking for three 120px columns: table-layout: fixed with width: auto gives [140, 243, 120] — the automatic algorithm's own answer, because a fixed layout needs a definite width to be in effect at all, so the property can be set and be doing nothing (width: max-content is not definite either, and behaves the same). With width: 100% it gives [200, 200, 200] — the space the columns did not ask for is shared out between them, so dragging one column moves every column and the one you dragged does not land where you let it go, which is exactly where shadcn/ui's own <Table> puts you because it renders w-full. Only with width: 360px, the sum, do you get [120, 120, 120]. So the table's width is not a style anyone picks; it is the sum of the columns, recomputed on every drag, which is what this hands you. Adding min-width: 100% to make a narrow table fill its container puts it straight back to [199.66, 199.66, 199.69]; and a <col> left unsized among sized ones is not given its content's width but whatever is left over, which is nothing, so it measures 0 and the column disappears — which is why every column is given a width and not just the ones somebody dragged. The handle is a role="separator" with aria-valuenow, aria-valuemin, aria-valuemax and arrow keys, plus Page Up/Down and Home/End, so a column can be resized without a pointer — the version without that is a feature a keyboard user cannot see is missing, and an absent aria-valuemax is 100 per ARIA, so a handle reporting 240 announces a nonsense percentage. It is nine pixels of transparent strip around a one-pixel line, because the line is what you aim at and a one-pixel target is not one; it captures the pointer, or widening the column moves the handle out from under the finger and the drag ends on its first move; it calls preventDefault and stopPropagation so a resize does not also sort the column underneath; a cancelled pointer reverts instead of committing a width nobody chose, and so does Escape; and the gesture is measured from its origin rather than accumulated per move, so a drag pushed past the minimum widens again the moment it comes back instead of owing the distance back. Right-to-left tables mirror both the drag and the arrow keys, read from the element at the time of the gesture. onWidthsChange fires once per gesture with the whole map, not per pointer move, because a column width is a stored preference and not something to write to localStorage sixty times a second — during the drag only a guide line moves, and nothing re-renders. Pass preview="live" to resize the column itself as you drag, which is still written straight to the DOM rather than through state. Composes with the other two pieces that go in a <th> and leave your markup alone: sort-header for the heading and sticky-table-header for pinning the header row and the leading columns. Widths are a plain Record<string, number> keyed by column id, so they survive hiding and reordering columns and can be stored and restored as they are. useColumnResizer, resolveColumnWidths, tableWidthFor, columnWidthFromDrag, keyboardColumnWidth and autoFitColumnWidth are exported for a header laid out as divs. One file, no dependencies, and your <table> stays yours.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/column-resizer.json"