Smart Column Width

Size columns to their content. Opt-in via the React <Table autoColumnWidth> prop — off by default, so existing layouts are unchanged.

How to Enable

// Default policy: fit to container, leave underflow, sample 100 rows.
<Table table={table} autoColumnWidth />

// Tuned:
<Table
  table={table}
  autoColumnWidth={{
    sampleSize: 100,     // rows sampled for measurement (default 100)
    overflow: 'fit',     // 'fit' | 'scroll' (default 'fit')
    underflow: 'leave',  // 'leave' | 'distribute' | 'stretch' (default 'leave')
    fitThreshold: 0.15,  // compress instead of scroll when overflow15% (default 0 = off)
  }}
/>

Overflow modes (natural total > container)

  • fit — never scroll horizontally. Over-wide columns are squished and their cells wrap, taking width first from the columns with the most slack, never below a floor (minSize, or 48px).
  • scroll — keep natural widths and allow horizontal scrolling.

Small-overflow compression (fitThreshold)

A few-pixel horizontal scrollbar is almost always an accident. fitThreshold (a fraction of the container, e.g. 0.15 = 15%) says "keep it in view if it nearly fits": when natural widths overflow by ≤ the threshold, columns are proportionally compressed to fit without wrapping (respecting minSize) instead of scrolling. Above the threshold, the normal overflow behavior applies.

Because compression is pure width math (no wrapped-row heights to measure), it works even under row virtualization — unlike overflow: 'fit', which wraps and so falls back to scroll when virtualized. It also survives async re-measures and never touches user-resized columns.

Underflow modes (natural total < container)

  • leave — columns keep their natural width.
  • distribute — extra space is spread proportionally across auto-sized columns in a waterfall that respects each maxSize as a hard cap: a column that would exceed maxSize is pinned and its leftover cascades to the uncapped columns. A gutter remains only when every auto column is capped.
  • stretch — same waterfall, but maxSize is a soft cap: if space is still left after all columns cap, they grow past maxSize proportionally so the container fills exactly — never a dead gutter (handy for modal/settings tables with a few capped columns).

Per-column opt-out

A column with an explicit size or enableAutoSize: false keeps its width and is excluded from measurement and squishing:

columnHelper.accessor('status', { header: 'Status', size: 120 })
columnHelper.accessor('actions', { header: '', enableAutoSize: false })

Computed widths flow through columnSizing, so pinned offsets, the <colgroup>, and virtualization totals stay in sync. Dragging a column's resize handle excludes it from further auto-sizing.

Measuring rendered content

By default a column is measured by its raw accessor value. When the cell renders something wider — a formatter or custom cell (accessor 19 shown as "$19.00 (Gold)") — the raw value under-measures and clips. Two per-column overrides fix it:

columnHelper.accessor('price', {
  cell: ({ getValue }) => formatCurrency(getValue()),
  autoSizeText: (row) => formatCurrency(row.original.price), // measure what renders
})
columnHelper.accessor('trend', {
  cell: ({ getValue }) => <Sparkline value={getValue()} width={80} />,
  autoSizeWidth: () => 80, // exact px, bypasses text measurement (no padding added)
})

Precedence: autoSizeWidth (exact px, verbatim) → autoSizeText (measured string, gets padding + sort-indicator) → raw accessor value.

Async values (re-measure)

When cell values arrive after mount (rows render with placeholders like $0 / '-', then real values merge in from a separate query), the first measurement sizes on the placeholder. Auto-sizing corrects this by re-measuring whenever the row data reference changes identity — an async merge produces a new data array, which triggers a debounced (~60ms) re-measure. Sorting, filtering, and pagination do not re-measure (they change the derived row model, not data). Re-measure respects provenance: only auto-owned widths update; user-resized and persisted widths are untouched.

If values merge without a new data array (in-place mutation), call the escape hatch when the merge is done:

table.remeasureColumns() // immediate re-measure (no debounce)
table.remeasureColumns('prices-loaded') // optional reason, forwarded on the event

table.remeasureColumns(reason?) emits 'columns:remeasure'; it is a no-op when smart width is off and only re-sizes auto-owned columns.

minSize is a hard floor

A column can never render below its minSize — core getSize clamps every width up to minSize. Auto-sizing treats it as a hard floor: content narrower than minSize renders at minSize. Want tighter auto-sizing? Lower or omit minSize.

Persisted widths

The hook never overwrites a width it didn't write this session — a user-dragged width or one restored from persisted columnSizing is treated as user-set and left alone.

Persisting auto-sized widths neuters smart width. If you save the whole columnSizing state (it includes the widths auto-sizing computed) and restore it on the next load, those restored widths look user-set to the provenance rule and are skipped — the column stops auto-measuring, and async re-measure and stretch won't touch it. Prefer not persisting columnSizing under autoColumnWidth (auto recomputes from content every load), or persist only the columns the user actually resized (mark them at resize time) and restore just those.

Limitations

  • Custom cell renderers (badges, progress, custom components) or formatted text measure by the raw string value — use autoSizeText / autoSizeWidth above, or give them an explicit size / enableAutoSize: false.
  • Row virtualization: wrapped row heights aren't measured, so under row virtualization overflow: 'fit' falls back to scroll behavior and logs a one-time dev warning. The supported way to get fit + wrap under virtualization is Pretext-measured row heights; otherwise disable virtualization or set overflow: 'scroll'.