Column Freezing (Freeze / Unfreeze) Implementation in Vanilla-Grid

This document explains how column freezing is implemented in Vanilla-Grid, including freeze state modeling, left-offset pinning, header/body synchronization, context-menu actions, persistence, and interactions with ordering/visibility.

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


1. Feature Scope

Column freezing supports:

Public API:


2. Internal Data Model

Freezing uses:

  1. _frozenColumnKeys: Set<string>
    • Normalized keys of currently frozen data columns
  2. this.columns
    • Runtime display order used by rendering, resizing, and virtualization

A key design rule is that frozen columns are kept as a contiguous prefix in this.columns (after the optional selection column).


3. Key Normalization and Eligibility

Freeze API calls normalize keys through _normalizeColumnKey(...).

freezeColumn(columnKey) rejects these cases (returns false):

  1. Empty/invalid normalized key
  2. Selection column key (__vgSelection__)
  3. Already frozen key
  4. Key not present in current this.columns

Successful freeze/unfreeze operations return true.


4. Freeze / Unfreeze API Behavior

4.1 freezeColumn(columnKey)

Flow:

  1. Normalize and validate key.
  2. Add key to _frozenColumnKeys.
  3. Re-group columns via _reorderColumnsForFreeze().
  4. Rebuild header/layout via refreshHeaderLayout().
  5. Re-render visible rows via renderVisibleRows(true).
  6. Persist frozen state via persistFrozenColumnsToStorage().

4.2 unfreezeColumn(columnKey)

Flow:

  1. Normalize key and require key to exist in _frozenColumnKeys.
  2. Delete key from set.
  3. Run the same layout/render/persist pipeline.

4.3 unfreezeAll()

Flow:

  1. If set is already empty, return false.
  2. Clear _frozenColumnKeys.
  3. Run the same layout/render/persist pipeline.

4.4 Query APIs


5. Column Re-grouping Strategy

_reorderColumnsForFreeze() enforces contiguous grouping:

  1. Split current this.columns into frozen and unfrozen buckets.
  2. Exclude selection column from both buckets and reinsert it first if present.
  3. Rebuild this.columns = [selection?, ...frozen, ...unfrozen].

Notes:

This keeps pinned behavior simple and deterministic because frozen columns always occupy the left-most range.


6. Left Offset Computation and Pinning

_computeFrozenLeftOffsets() calculates per-column left offsets using rendered header cell widths (offsetWidth) instead of colgroup style widths.

Behavior:

  1. Iterate columns from left to right.
  2. Treat selection column and frozen keys as frozen.
  3. Accumulate left offset by measured header width.
  4. Stop on first non-frozen column (contiguous-prefix assumption).

Return shape:

[{ index: 0, left: 0 }, { index: 1, left: 28 }, ...]

7. Applying Frozen Styles

_applyFrozenColumnStyles() applies computed frozen state to header and rows.

7.1 Header behavior

Header cells use a different pinning mechanism than body cells:

7.2 Body behavior

_applyFrozenStylesToPoolRows(offsets, frozenCount) runs over pooled virtual rows:

This runs against row pool elements, so virtualization reuse remains compatible with freezing.


8. Freeze Guide Overlay

A dedicated vertical separator is rendered as .vn-grid-freeze-guide:

The guide spans container height (header + body) and replaces per-cell separator pseudo-elements to avoid border-collapse gaps.

8.1 Freeze guide visibility during loading lifecycle

The visibility logic is centralised inside _updateFreezeGuide() itself: before positioning the guide it checks displayRows.length and isLoading. If the grid body has neither data rows nor shimmer skeletons, the guide is hidden regardless of which caller triggered the update. This prevents stale freeze-guide re-appearances from asynchronous code paths such as the viewport-resize settle timer.

Grid state Freeze guide
Initial (empty body, before fetch) Hidden — _updateFreezeGuide() sees no data rows and isLoading === false, so it calls _hideFreezeGuide().
Shimmer loading Visible — isLoading is true, so _updateFreezeGuide() positions and shows the guide alongside the skeleton rows.
Data loaded Visible — displayRows.length > 0, so the guide is shown normally.
Empty message ("no data") Hidden — showEmpty() calls _hideFreezeGuide() explicitly, and any subsequent _updateFreezeGuide() call also stays hidden because displayRows is empty and isLoading is false.
Error message Hidden — showError() calls _hideFreezeGuide() for the same reason, and the guard in _updateFreezeGuide() prevents re-show.

9. Context Menu Integration

Header right-click (_onHeaderContextMenu) dynamically includes freeze actions:

Menu labels are localized through:

Each action calls the public API and closes the menu via existing pointer/escape handlers.


10. Persistence Model

Control options:

10.1 Storage key

resolveFrozenColumnsStorageKey() default:

vanilla-grid:frozen-columns:{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'

10.2 Stored format

JSON array of normalized keys:

["name", "status"]

10.3 Save path

persistFrozenColumnsToStorage():

10.4 Load path

loadFrozenColumnsFromStorage():

If parse fails, warning is logged and set resets to empty.


11. Interactions with Other Features

11.1 Column reordering

After drag-drop reorder succeeds, grid re-applies _reorderColumnsForFreeze() plus full layout/render refresh so frozen and unfrozen groups remain separated.

11.2 Column visibility

hideColumn(...) explicitly removes hidden key from _frozenColumnKeys before running visibility refresh. Hiding a frozen column therefore auto-unfreezes it.

11.3 Row grouping

A frozen column that becomes a group level is a different case from a hidden one: it keeps its key in _frozenColumnKeys and simply stops appearing, because grouping removes it from the visible array through a derived exclusion rather than through hideColumn() (see Column Visibility Implementation § 5.1). The remaining frozen columns are still a contiguous leading run after _reorderColumnsForFreeze(), so no offset math changes — and ungrouping brings the column back still frozen, with no state to restore, because none was ever discarded.

11.4 Virtualization

Frozen styles are applied to pooled rows, so freezing integrates with row recycling without requiring a non-virtual render mode.

11.5 Horizontal scroll sync

Freezing depends on syncHeaderHorizontalScroll() counter-transform logic. Without this, header frozen cells would shift with the translated header table.


12. CSS Contract

Key classes in vanilla-grid.css:

Theme variables consumed by frozen styling include:

This allows frozen columns to remain visually consistent across themes and row states (normal/hover/selected).


13. Example Configuration

const grid = new VanillaGrid({
  persistence: {
    frozenColumns: { enabled: true, storageKey: 'my-grid:frozen:v1' }
  }
});

grid.freezeColumn('name');
grid.freezeColumn('status');
grid.unfreezeColumn('status');
// grid.unfreezeAll();

Declarative frozen columns

The frozen attribute on <vn-grid-column> sets the initial freeze state:

<vn-grid-column field="Name" header="Name" type="string" frozen="true"></vn-grid-column>

Or as a bare attribute (equivalent to frozen="true"):

<vn-grid-column field="Name" header="Name" type="string" frozen></vn-grid-column>

The column starts frozen but the user can unfreeze it via the context menu. Once the user interacts with freeze state (any freeze/unfreeze action), the state is persisted to localStorage, and subsequent page loads use the persisted state instead of the declarative default.

For session-only behavior:

persistence: { frozenColumns: { enabled: false } }

14. Viewport Clamping of Frozen Columns

14.1 Problem

When frozen columns are wider than the browser viewport, position: sticky causes frozen cells to cover 100% of the visible area. Unfrozen columns still exist in the DOM but are hidden behind the opaque sticky cells, making them unreachable by horizontal scrolling. A secondary symptom is a spurious double horizontal scrollbar.

This affects single and multi-column freeze alike — with three frozen columns and a narrow viewport, the combined frozen width can easily exceed the available space.

14.2 Why previous approaches failed

Approach Outcome
Remove width: 100% / minWidth from tables Broke initial table layout; rows appeared empty
Compute offsets from <col> widths instead of offsetWidth No visible improvement
Disable position: sticky entirely when frozen > viewport Broke freeze guide, column resize, and scroll bar visibility
Set max-width on frozen <td> elements Ignored by table-layout: fixed — column width is governed solely by <col> elements

14.3 Solution — _clampFrozenColumnsToViewport()

The fix operates on the actual <col> elements in the header and body <colgroup>, which is the only mechanism that controls column widths under table-layout: fixed.

Located in columns.feature.js, the method is called at the top of applyFrozenColumnStyles() and also re-triggered from _onViewportResized() so the grid re-evaluates on browser resize.

Algorithm

  1. Restore originals — All previously clamped columns are restored to their stashed original widths so measurements start from true values.
  2. Measure — Sum widths of all frozen <col> elements.
  3. Evaluate — If total frozen width ≤ viewportWidth − 50px, no clamping is needed; the stash is cleared and the method returns.
  4. Distribute excess right-to-left — The excess (totalFrozenWidth − maxAllowedFrozen) is absorbed column by column starting from the rightmost frozen column. Each column can shrink down to its configured minimum (default 60 px). If one column cannot absorb all excess, the next column to the left is shrunk, and so on.
  5. Update table width — After clamping, the total table width is recalculated from all <col> widths and applied to both header and body <table> elements.

State management

Original widths are stored in a Map<number, number> (this._frozenColOrigWidths) keyed by column index. This allows per-column restoration when the viewport grows wide enough again, and correctly handles any number of frozen columns.

14.4 Constants

Name Value Purpose
minUnfrozenPeek 50 px Minimum visible space reserved for unfrozen columns
MIN_COLUMN_WIDTH_PX 60 px Smallest width any frozen column can be clamped to (unless explicit minWidth is set lower); defined in features/columns-resize.feature.js

14.5 Integration points

14.6 Edge cases