Column Resizing Implementation in Vanilla-Grid

This document explains how column resizing is implemented in Vanilla-Grid, including pointer lifecycle, live width updates, resize-guide rendering, frozen-column interaction, and width persistence.

File location (May 2026): the resize logic lives in src/vanilla-grid/features/columns-resize.feature.js as a prototype extension of VanillaGridColumnsFeature.


1. Feature Scope

Column resizing supports:

Related options:


2. Structural Primitives

Resizing is built on three structural elements:

  1. Header <colgroup> (headerColGroup) — source-of-truth for column widths
  2. Body <colgroup> (bodyColGroup) — mirrored from header widths
  3. Header resize handles (.vn-grid-col-resizer) inside each th (except selection checkbox column)

Base CSS geometry:

This combination gives predictable width math and avoids content-driven reflow surprises.


3. Initialization and Listener Wiring

In renderHeader():

When column.resizable is false:

The HTML attribute resizable="false" on <vn-grid-column> maps to column.resizable = false.

For resizable columns, the delegated hover-tooltip mousemove handler caches each resizer's bounding rect in a WeakMap keyed by the resizer element. Entries are invalidated by an instance-level generation counter (_resizerRectGeneration) that the grid's single window-resize handler (_handleResize) bumps, and dropped on hover exit. No per-resizer listeners or window listeners are registered, so header rebuilds cannot leak anything.

startResize attaches document-level listeners via:

Listeners are detached in detachResizeListeners() and again during destroy() for safety.


4. Resize State Machine

resizeState stores active interaction state:

4.1 Start (startResize)

startResize(e) does:

  1. Validate target column index.
  2. Freeze current measured widths into colgroups:
    • freezeAllColumnWidths(false)
    • syncBodyColWidths()
  3. Capture starting widths for all columns into allStartWidths array (enables cascading).
  4. Populate resizeState.
  5. Set cursor to col-resize.
  6. Attempt setPointerCapture on resizer.
  7. Show resize guide for eligible columns.
  8. Attach move/up listeners.

4.2 Move (handleResizeMove)

handleResizeMove(e) behavior:

Inside the rAF callback:

4.3 Stop (stopResize)

stopResize():

  1. Releases pointer capture if held.
  2. Cancels pending rAF.
  3. Detaches listeners.
  4. Persists final widths (saveColumnWidthspersistColumnWidthsToStorage).
  5. Recalculates table width policy (adjustTableWidthAfterResize).
  6. Hides guide and resets state (_resetResizeState).
  7. Restores cursor/user-select.
  8. Reapplies frozen styles if active.

5. Width Math and Constraints

5.1 Minimum widths

_getMinColumnWidth(column):

The 96 floor exists because below it the header's own text/sort-indicator content no longer fits inside the filter funnel's reserved zone (th:has(.vn-grid-filter-icon) .vn-grid-header-label { padding-right: 27px } in vanilla-grid.css) and visually collides with the funnel. It is a worst-case constant across shipped themes (th padding + ellipsis-floor text box + text/indicator gap + sort-indicator box + funnel reserve), not a live DOM measurement — resize is a per-frame path, so it deliberately avoids the getComputedStyle reads autoFitColumn's width math performs once per gesture (§14).

Consumers can set minWidth on any column definition. The HTML attribute min-width on <vn-grid-column> is mapped to column.minWidth by the web component.

getColWidth(...) always returns at least this minimum.

5.2 Non-last column behavior (cascading resize)

For a non-last column, the grid uses cascading resize. Instead of only affecting the immediate neighbor, space can be borrowed from multiple columns in the drag direction.

Dragging right (growing the active column):

  1. Calculate total available slack from columns to the right: for each column i from idx+1 to the last, slack = startWidth[i] - minWidth[i].
  2. The actual grow amount is clamped to the total available slack.
  3. Shrinkage is distributed across right columns sequentially (nearest neighbor first).

Dragging left (shrinking the active column, growing right-side columns):

  1. Calculate total available grow capacity from columns to the right: for each column i from idx+1 to the last, grow = maxWidth[i] - startWidth[i]. Locked (non-resizable) columns have maxWidth = currentWidth, contributing 0.
  2. Calculate total available shrink capacity from columns to the left: for each column i from idx down to 0, slack = startWidth[i] - minWidth[i].
  3. The actual movement is clamped to the minimum of both capacities.
  4. Growth is distributed across right columns sequentially (nearest neighbor first); locked columns are naturally skipped because their grow capacity is 0.
  5. Shrinkage is distributed across left columns sequentially (nearest neighbor first).

This ensures that a column resize is never blocked just because the immediate neighbor is at its minimum width, as long as there is space available further along.

All width calculations use the allStartWidths array captured at drag start, so each animation frame recalculates from the same baseline (no cumulative drift).

5.2b Maximum widths

_getMaxColumnWidth(column):

Consumers can set maxWidth on any column definition. The HTML attribute max-width on <vn-grid-column> is mapped to column.maxWidth by the web component.

When set, both live drag resize and auto-fit will cap the column width at this value. Cascading resize clamps growth against the active column's maxWidth before distributing shrinkage to neighbors.

5.3 Last column behavior (independent)

For the last column:

maxShrink = startWidth - currentMinCol
maxGrow   = currentMaxCol - startWidth
clampedDx = min(maxGrow, max(-maxShrink, dx))

6. Header/Body Synchronization

syncBodyColWidths() copies header col widths into body col widths and computes total width sum.

If total width > 0:

This enforces deterministic alignment under table-layout: fixed and keeps body/header columns pixel-synchronized during drag.


7. Table Width Reconciliation After Drag

adjustTableWidthAfterResize() ensures correct horizontal-scroll behavior:

  1. Sums explicit column widths.
  2. Compares to viewport.clientWidth.
  3. If sum <= viewport width:
    • sets both tables to viewport width (no horizontal overflow)
  4. Else:
    • sets both tables to exact sum width (horizontal scroll enabled)
  5. Updates scrollbar layout/thumb.

This prevents stale overflow after resizing the final columns.

7.1 stretchToFit and the smart distribution pass

The stretch-to-fit logic lives in its own feature file — src/vanilla-grid/features/stretch-to-fit.feature.js — which extends VanillaGridColumnsFeature.prototype with two new methods:

It is invoked from three places: the grid-core post-render hook (_absorbViewportSurplusToFlexColumn now just delegates), the viewport resize debouncer, and adjustTableWidthAfterResize (when stretch is on). It is also invoked directly (with persist: true) at the end of stopResize and autoFitColumn so that the persistence invariant below is maintained.

Persistence invariant (stretch-to-fit ON). After every applyStretchToFit({ persist: true }) call — including the delta < 1 "nothing to do" early return — the in-memory savedColumnWidths map and the on-storage snapshot are synced from the live inline widths via _persistStretchFlexWidths(flex, widths). This guarantees savedColumnWidths.get(key) === inlineWidth for every flex column once a stretch pass has run. The invariant is what allows freezeAllColumnWidths(false) (called at startResize) to remain a visual no-op: without it, the column would jump to the stale saved value the instant the user grabs the resize handle.

freezeAllColumnWidths(false) itself is also stretch-aware: when stretch is active and the column has a meaningful live measured width (measured > minWidth), the live value takes priority over savedColumnWidths. This is a defensive second line of defence — even if some future code path leaves a stale entry in the map, the user's resize-start will still not bump the column away from the cursor.

Behaviour depends on the stretchToFit option:

Algorithm. Eligible (flex) columns get a proportional share of the delta (viewport - lockedTotal - flexTotal - safetyMargin) via classic water-filling: every round, each still-active slot receives a share proportional to its remaining capacity; columns that hit their cap drop out and the leftover is redistributed among the survivors. Columns with no maxWidth (unbounded) share any leftover that bounded columns cannot absorb. Convergence is bounded by the number of flex columns.

autoFitAllColumns coordination. When stretch is on, the per-column inflate-the-flex-column trick inside autoFitColumn is suppressed (via the _suppressStretchAbsorb flag set by autoFitAllColumns). After the N per-column autofits complete, applyStretchToFit({ persist: true }) runs exactly once to compute and persist the final layout. This avoids both the O(N) layout cost and the per-iteration overshoot of maxWidth that the legacy single-column absorb produced.


8. Resize Guide Overlay

Guide element lifecycle:

Positioning model:

Special-case:


9. Frozen Column Interaction

When frozen columns exist:

This recalculates sticky offsets so frozen cells remain pinned correctly while widths change.

Additionally, base CSS sets:

.vn-grid-header-table th.vn-grid-frozen-col-last .vn-grid-col-resizer {
  background: transparent !important;
}

and paints its pseudo-line with freeze boundary color to avoid a white-square artifact and keep visual continuity.


10. Persistence Model

10.1 Storage key

resolveColumnWidthsStorageKey() produces:

vanilla-grid:column-widths:{pathname}:{scope}

Where scope is first available of:

  1. gridId option (auto-populated from the <vanilla-grid> host element id)
  2. bodyTable.id
  3. viewport.id
  4. headerTable.id
  5. 'default'

Can be overridden with persistence.columnWidths.storageKey.

10.2 Save path

saveColumnWidths() reads header <col> inline widths into savedColumnWidths map, then calls persistColumnWidthsToStorage().

Persisted format:

{ "name": 220, "city": 180, "weight": 120 }

10.3 Load/apply path

This guarantees stable initial layout even when persisted data is absent, invalid, or when newly-visible columns have no saved width.


11. Failure Safety and Cleanup


12. Visual/Theming Hooks

Themes can customize resize visuals without changing behavior:

Behavioral geometry (handle width, absolute positioning, guide stacking) should stay in base CSS.


13. Example Configuration

const grid = new VanillaGrid({
  persistence: {
    columnWidths: { enabled: true, storageKey: 'my-grid:column-widths' }
  }
});

Per-column constraints:

<vn-grid-column field="Name" header="Name" min-width="100" max-width="400"></vn-grid-column>
<vn-grid-column field="Id" header="ID" type="number" resizable="false"></vn-grid-column>

For session-only sizing:

persistence: { columnWidths: { enabled: false } }

14. Auto-Fit API

14.1 Single-column auto-fit

autoFitColumn(columnKey, options?) sizes a column to fit its content:

Trigger points:

Restrictions:

// Programmatic single-column auto-fit
document.querySelector('vn-grid').autoFitColumn('Name');
// Force overwrite ignoring persisted width
document.querySelector('vn-grid').autoFitColumn('Name', { mode: 'overwriteAll' });

14.2 Auto-fit all visible columns

autoFitAllColumns(options?) iterates all columns left-to-right and calls autoFitColumn for each eligible column:

Trigger points:

// Auto-fit every visible, resizable column
document.querySelector('vn-grid').autoFitAllColumns();
// With custom min-width override
document.querySelector('vn-grid').autoFitAllColumns({ minWidth: 80 });
// Session-only — do not persist widths
document.querySelector('vn-grid').autoFitAllColumns({ persist: false });