Column Resizing
A column’s default width comes from its content and the table’s layout — usually fine, sometimes not. Long product names need more room than the default; an ID column can shrink. Column resizing gives users a drag handle on the header edge to set the width themselves, and the table remembers their choice once persistence is wired up.
Adding the drag handle
Section titled “Adding the drag handle”Mount ResizeHandle from the shadcn column-resize registry inside a HeaderEdge slot. The handle owns the pointer interaction, the live preview while dragging, and the publish of the final width back to the table — the column declaration only has to expose the slot.
import { DataTable, DataTableCell, DataTableColumn, DataTableColumnHeader, HeaderEdge } from '@/components/ui/data-table'
import { ResizeHandle } from '@/components/ui/data-table/column-resize'
import { localModel } from '@virtuoso.dev/data-table'
const rows = Array.from({ length: 60 }, (_, index) => ({
name: `Product ${index + 1}`,
category: ['Office', 'Peripherals', 'Audio'][index % 3]!,
stock: 8 + ((index * 7) % 41),
}))
const model = localModel({ data: rows })
export default function App() {
return (
<DataTable className="rounded-xl" model={model} style={{ height: 340 }}>
<DataTableColumn field="name">
<DataTableColumnHeader>
<HeaderEdge component={ResizeHandle} />
{() => 'Product'}
</DataTableColumnHeader>
<DataTableCell className="font-medium">{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
<DataTableColumn field="category">
<DataTableColumnHeader>
<HeaderEdge component={ResizeHandle} />
{() => 'Category'}
</DataTableColumnHeader>
<DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
<DataTableColumn field="stock">
<DataTableColumnHeader className="justify-end">
<HeaderEdge component={ResizeHandle} />
{() => 'Stock'}
</DataTableColumnHeader>
<DataTableCell className="text-right tabular-nums">{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
</DataTable>
)
}The slot alone is what enables resizing on a column:
<DataTableColumn field="name">
<DataTableColumnHeader>
<HeaderEdge component={ResizeHandle} />
{() => 'Product'}
</DataTableColumnHeader>
<DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>Skip HeaderEdge for columns the user shouldn’t be allowed to resize — a fixed-width row-selector column, for example.
Where column widths live
Section titled “Where column widths live”The table owns every column’s width through its header. It measures each DataTableColumnHeader, distributes any leftover viewport space across the visible columns, and uses the resulting size for every body cell in that column. The drag handle, the programmatic resizeColumn$ call, and the persisted overrides all feed into that same pipeline as runtime overrides on top of the measured base.
To set a column’s default width, style the header. The header row is a flex container, so width and flex utilities on DataTableColumnHeader work the way they would on any flex child — w-20, min-w-32, max-w-64, grow, shrink-0, basis-32:
<DataTableColumn field="id">
<DataTableColumnHeader className="w-20 grow-0">ID</DataTableColumnHeader>
<DataTableCell>{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>
<DataTableColumn field="name">
<DataTableColumnHeader className="min-w-48 grow">Product</DataTableColumnHeader>
<DataTableCell className="font-medium">{({ cellValue }) => String(cellValue)}</DataTableCell>
</DataTableColumn>Resizing without the drag handle
Section titled “Resizing without the drag handle”Some resizes don’t originate from the column edge. A “fit to content” toolbar button, a “Compact / Comfortable / Spacious” preset, a keyboard shortcut — each needs to set the width without anyone dragging. Publish to resizeColumn$ directly:
import { columns$, useEngineRef, useRemoteCellValue, useRemotePublisher } from '@virtuoso.dev/data-table'
import { resizeColumn$ } from '@virtuoso.dev/data-table/column-resize'
const engineRef = useEngineRef()
const columns = useRemoteCellValue(columns$, engineRef)
const resizeColumn = useRemotePublisher(resizeColumn$, engineRef)
function fitNameColumn() {
const nameKey = [...(columns ?? new Map())].find(([, column]) => column.field === 'name')?.[0]
if (nameKey) {
resizeColumn({ key: nameKey, width: 280 })
}
}The payload’s key is the column’s internal identifier, not its field. The lookup against columns$ translates a stable, user-facing field name into the key the stream expects.
Persisting widths across reloads
Section titled “Persisting widths across reloads”The handle records the user’s intent — “the user wants the name column 280px wide.” Intent is what gets saved; the realized width the column actually renders at depends on the table size and the other columns, and shifts as the layout shifts. Saving the realized number locks the user into the layout that happened to be on screen when they last touched the handle.
Mount columnWidthPersistenceAdapter() on DataTableStatePersistence to persist overrides. The adapter saves them keyed by field, so the saved entry survives reordering, partial column lists, and dynamically generated columns. State Persistence covers the full wiring, including the field-name contract that ties saved state to column declarations.
The full resize surface
Section titled “The full resize surface”Three publishers cover the runtime mutations:
resizeColumn$— set a width override for one column.clearColumnWidthOverride$— drop one override; the column falls back to its default.resetColumnWidthOverrides$— drop every override at once, e.g. a “Reset widths” menu item.
One adapter wires those into persistence:
columnWidthPersistenceAdapter()— saves the override map; see State Persistence.
One read-only cell exposes the realized layout, for layout-aware UI that needs the actual rendered widths:
columnWidths$— realized widths after distribution. Read for layout introspection; do not persist these values.
The Column Resizing example puts the handle, preset menus, and persistence together in one table.