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.jsas a prototype extension ofVanillaGridColumnsFeature(defined insrc/vanilla-grid/features/columns.feature.js).
1. Feature Scope
Column freezing supports:
- Freeze a column by key
- Unfreeze a specific column by key
- Unfreeze all frozen columns
- Query whether a column is frozen
- Query all frozen keys
- Use right-click header context menu actions for freeze/unfreeze
Public API:
freezeColumn(columnKey)unfreezeColumn(columnKey)unfreezeAll()isFrozenColumn(columnKey)getFrozenColumns()
2. Internal Data Model
Freezing uses:
_frozenColumnKeys: Set<string>- Normalized keys of currently frozen data columns
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):
- Empty/invalid normalized key
- Selection column key (
__vgSelection__) - Already frozen key
- Key not present in current
this.columns
Successful freeze/unfreeze operations return true.
4. Freeze / Unfreeze API Behavior
4.1 freezeColumn(columnKey)
Flow:
- Normalize and validate key.
- Add key to
_frozenColumnKeys. - Re-group columns via
_reorderColumnsForFreeze(). - Rebuild header/layout via
refreshHeaderLayout(). - Re-render visible rows via
renderVisibleRows(true). - Persist frozen state via
persistFrozenColumnsToStorage().
4.2 unfreezeColumn(columnKey)
Flow:
- Normalize key and require key to exist in
_frozenColumnKeys. - Delete key from set.
- Run the same layout/render/persist pipeline.
4.3 unfreezeAll()
Flow:
- If set is already empty, return
false. - Clear
_frozenColumnKeys. - Run the same layout/render/persist pipeline.
4.4 Query APIs
isFrozenColumn(columnKey)returns membership check on normalized key.getFrozenColumns()returnsArray.from(_frozenColumnKeys.values()).
5. Column Re-grouping Strategy
_reorderColumnsForFreeze() enforces contiguous grouping:
- Split current
this.columnsintofrozenandunfrozenbuckets. - Exclude selection column from both buckets and reinsert it first if present.
- Rebuild
this.columns = [selection?, ...frozen, ...unfrozen].
Notes:
- Relative order within frozen and unfrozen groups is preserved.
- If there are no frozen keys, method exits early.
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:
- Iterate columns from left to right.
- Treat selection column and frozen keys as frozen.
- Accumulate
leftoffset by measured header width. - 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
- Adds
vn-grid-frozen-colto frozen header cells - Marks the last frozen cell with
vn-grid-frozen-col-last - Does not set
leftfor header cells
Header cells use a different pinning mechanism than body cells:
- Header table is translated by
translateX(-scrollLeft)insyncHeaderHorizontalScroll() - Frozen header cells receive counter-transform
translateX(+scrollLeft) - Net effect: non-frozen headers scroll; frozen headers stay visually pinned
7.2 Body behavior
_applyFrozenStylesToPoolRows(offsets, frozenCount) runs over pooled virtual rows:
- Frozen cells: add
vn-grid-frozen-col, setstyle.leftto computed offset - Last frozen cell: add
vn-grid-frozen-col-last - Non-frozen cells: remove classes and clear
left
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:
- Created lazily by
_ensureFreezeGuide() - Positioned by
_updateFreezeGuide()at the right edge of the last frozen header cell - Hidden when no frozen columns (
_hideFreezeGuide()) - Removed on destroy (
_destroyFreezeGuide())
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:
- If target column is not frozen: show Freeze column
- If target column is frozen: show Unfreeze column
- If any column is frozen: also show Unfreeze all
Menu labels are localized through:
messages.freezeColumnmessages.unfreezeColumnmessages.unfreezeAll
Each action calls the public API and closes the menu via existing pointer/escape handlers.
10. Persistence Model
Control options:
persistence.frozenColumns.enabled(defaulttrue)persistence.frozenColumns.storageKey(optional explicit key)
10.1 Storage key
resolveFrozenColumnsStorageKey() default:
vanilla-grid:frozen-columns:{pathname}:{scope}
Where scope is first available of:
gridIdoption (auto-populated from the<vanilla-grid>host elementid)bodyTable.idviewport.idheaderTable.id'default'
10.2 Stored format
JSON array of normalized keys:
["name", "status"]
10.3 Save path
persistFrozenColumnsToStorage():
- no-op if persistence disabled or key unavailable
- guards
window/window.localStorage - serializes set values as JSON array
- wraps write in
try/catchwith warning log
10.4 Load path
loadFrozenColumnsFromStorage():
- resets
_frozenColumnKeys = new Set() - parses JSON array
- normalizes keys
- skips invalid keys and selection column
- fills set from parsed values
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:
.vn-grid-frozen-col.vn-grid-frozen-col-last.vn-grid-freeze-guide
Theme variables consumed by frozen styling include:
--vn-grid-frozen-bg--vn-grid-frozen-header-bg--vn-grid-frozen-border-color— colours both the full-height guide and the header's resize indicator on the last frozen column, so it must contrast with the frozen body and header backgrounds (see 15-themes-implementation.md § 4.2)--vn-grid-frozen-bg-selected--vn-grid-frozen-bg-hover
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
- Restore originals — All previously clamped columns are restored to their stashed original widths so measurements start from true values.
- Measure — Sum widths of all frozen
<col>elements. - Evaluate — If total frozen width ≤
viewportWidth − 50px, no clamping is needed; the stash is cleared and the method returns. - 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. - 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
applyFrozenColumnStyles()— calls_clampFrozenColumnsToViewport()before applying sticky offsets and CSS classes._onViewportResized()settle timeout — calls_applyFrozenColumnStyles()so the clamp is re-evaluated whenever the viewport dimensions change._updateTableWidthAfterClamp()— helper that recalculates and setstable.style.widthon both header and body tables after column widths are modified.
14.6 Edge cases
- Single frozen column — the rightmost (and only) column absorbs all excess.
- Multiple frozen columns — excess is distributed right-to-left; all columns can potentially shrink.
- Viewport grows back — originals are restored from the stash map; if no clamping is needed, the map is cleared.
- No frozen columns — method returns immediately (
frozenCount === 0). - Very narrow viewport — columns shrink to their minimum (default 60 px) each; if that is still too wide, the grid degrades gracefully with whatever space remains.