Skip to content

ColumnResizeHandle

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/column-resize-handle.json
See it liveA table whose headings end in a handle - drag one, double-click it to give the column its natural width back.→

Every heading ends in a thin handle; drag it to make the column wider or narrower, and double-click it to give the column its natural width back. Three pieces: the handle, useColumnWidths that keeps what was dragged, and measureColumns that reads a header row off the screen. Shown here against Table, which is where it goes.

const widths = useColumnWidths<'title' | 'owner' | 'words'>({
initial: loadWidths,
onChange: saveWidths,
fallback: (id) => NATURAL[id],
})
const header = useRef<HTMLTableRowElement>(null)
<Table className={widths.sized ? 'table-fixed' : undefined}>
<colgroup>
{columns.map((id) => (
<col key={id} style={widths.sized && id !== 'title' ? { width: widths.widthOf(id) } : undefined} />
))}
</colgroup>
<TableHead>
<TableRow ref={header}>
{columns.map((id) => (
<TableHeader key={id} data-column={id} className="relative">
{LABEL[id]}
<ColumnResizeHandle
label={t('table.resize', { column: LABEL[id] })}
hint={t('table.resizeHint')}
onStart={() => header.current && widths.measure(measureColumns(header.current))}
onResize={(px, done) => widths.resize(id, px, done)}
onReset={() => widths.reset(id)}
/>
</TableHeader>
))}
</TableRow>
</TableHead>
…
</Table>

The cell is relative; the handle is absolute on its right edge. It measures its parent’s box on the press and reports that width plus the distance travelled, so it has no width prop to be handed and cannot disagree with what is on screen.

The widths live on <col>s, and only once something was sized by hand. Until then the browser lays the table out from its contents as it always did. The first drag switches the table to table-layout: fixed, and under fixed layout every column needs a width or the browser shares the free space equally between those without one — which is how a date column ends up as wide as the title. So onStart measures the header row before the layout goes fixed, the hook keeps those natural widths beside the dragged ones, and widthOf answers from one or the other. Leave the one column meant to take the remaining space without a <col> width.

onResize reports while dragging and once more with done. The last call is the moment to persist; useColumnWidths does that for you through onChange. Widths belong per machine, not to a profile: the pixels that fit a title on a laptop are not the pixels that fit it on a monitor.

Pointer events, not HTML5 drag-and-drop. A desktop shell that takes file drops for itself never lets a dragstart reach the page. Pointer capture keeps the drag alive when the pointer runs ahead of the cell.

It is a separator, not a button. A button per column would be one more tab stop for something a keyboard cannot usefully drive. The click after a drag is stopped so a sortable heading does not flip its sort every time a width is set.

The pieces around the handle are not components, so they are listed here. useColumnWidths({ initial, onChange?, fallback?, minWidth? }) returns { sized, hand, isHandSized(id), widthOf(id), resize(id, width, done?), reset(id), measure(cells) }. measureColumns(row, attribute = 'data-column') returns the [id, width] pairs of a header row’s cells that carry the attribute.

Props

Read from the types in ui/column-resize-handle.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.

PropTypeDefault
labelrequiredstringNames the column for assistive technology: "Resize the Tier column".
hintstringShown on hover: how to drag and how to reset.
onResizerequired(width: number, done: boolean) => voidThe width while dragging, and once more with done when the pointer is let go - the moment to persist.
onResetrequired() => voidDouble-click: the column goes back to its natural width.
onStart() => voidCalled on pointerdown, before the first onResize - the moment for the caller to measure what every column is before the layout goes fixed.
minWidthnumber56Narrower than this and the column is a stripe with nothing in it.
classNamestring

Accessibility

The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 19 rules applied and passed, and none failed. The accessibility report has every item.

History

Each release that changed ui/column-resize-handle.tsx - the file a product copies - newest first.

  1. v0.26.0
    • feat a header edge to drag, rows that reorder, a funnel with a panel 8310586