Row Virtualization and Custom Scrollbars
This document describes the internal implementation of VanillaGrid's two foundational rendering systems: the virtual row pool that makes large datasets performant, and the custom dual-axis scrollbar that replaces the browser's native scrollbar.
1. Row Virtualization
1.1 Problem
A grid with 100,000 rows cannot render all <tr> elements at once without exhausting memory and making every DOM operation slow. The virtualization system solves this by maintaining a small, fixed-size pool of reusable row elements and only populating them with the data that falls within the current viewport.
1.2 DOM Structure
The grid body is a standard <table> whose <tbody> contains three logical sections laid out as actual DOM rows:
<tbody>
<tr><td style="height: Npx"></td></tr> ← top spacer row
<tr>…</tr> ┐
<tr>…</tr> │ pool rows (fixed count)
<tr>…</tr> ┘
<tr><td style="height: Mpx"></td></tr> ← bottom spacer row
</tbody>
The two spacer rows give the table its full scrollable height without materialising any real data. Their heights are adjusted on every render pass to match the number of invisible rows above and below the pool. Each spacer <td> carries the vn-grid-row-spacer-cell class, which paints the placeholder-stripe background described in §1.9.
1.3 Pool Sizing
Pool size is computed in initVirtualPool():
visibleRows = ceil(viewportHeight / rowHeight)
poolSize = min(totalRows, visibleRows + bufferRows × 2)
totalRows here (and everywhere else in this document) is renderEntries.length, not displayRows.length. When row grouping (Row Grouping Implementation) is active, renderEntries interleaves caption rows with data-row references; when inactive it is the same reference as displayRows, so every formula on this page is unchanged for a grid that never groups.
bufferRows adds extra rows above and below the visible window. This means the pool always has more rows than fit on screen, so small scrolls can be satisfied by repositioning spacers without touching DOM rows at all. Default is 20 on fine-pointer devices (mouse/trackpad — wheel and scrollbar deltas are bounded, see §2.12); matchMedia('(pointer: coarse)') devices (touchscreens) default to 40 instead, since touch momentum sustains far higher per-frame deltas than wheel input ever does and needs more compositor headroom. Either can be overridden via layout.bufferRows.
The pool is stored in this.poolRows — an array of pre-built <tr> elements that persist across renders.
Directional buffer skew
The pool stays a fixed size (bufferRows × 2), but renderVisibleRows() no longer always splits it symmetrically above/below the visible window. A smoothed estimate of recent scroll velocity (logical px/ms, EMA-smoothed from consecutive handleScroll() deltas, decaying to 0 once events stop arriving frequently) skews the split toward the direction of travel — up to a 25%/75% split at velocities at or above ~4,000 logical px/s (within typical touch-momentum flick range). The same rows are simply spent where the compositor is heading instead of protecting a direction the user is scrolling away from. Zero extra memory or per-pass cost.
1.4 Pool Reuse vs. Full Rebuild
initVirtualPool() checks whether the existing pool is still valid before rebuilding:
if (
this._poolColCount === colCount && // column count unchanged
this.poolRows.length >= poolSize && // pool is large enough
this.topSpacerCell &&
this.bottomSpacerCell
) {
// Reuse: re-park the spacers around the pool and hide excess rows
return;
}
// Full rebuild into a DocumentFragment — single DOM insertion
A full rebuild only happens when:
- The pool is first created.
- The column count changes (e.g. a column is hidden/shown).
- The viewport grew so much that
poolSizeexceedspoolRows.length.
The entire pool is assembled in a DocumentFragment and appended with a single body.appendChild(fragment), causing only one layout reflow.
Both branches must leave the scroll offset where they found it (_parkSpacersPreservingScrollOffset()). A pool rebuild is not a scroll event: the top spacer is re-parked at the height it already had — clamped down only to what the new render-entry count can carry — and the bottom spacer absorbs the rest of the surface, so viewport.scrollTop is unchanged when the caller renders next.
Parking both spacers at 0px instead (as the reuse branch used to) deletes every pixel of content that sat above the scroll position, and the browser answers that by shifting viewport.scrollTop down by the same amount. renderVisibleRows() reads scrollTop back on its very next line, so it renders the top of the dataset; the spacers it writes then restore the full surface, leaving the viewport scrolled deep into the data with nothing but the bottom spacer under it — a blank grid. Callers that rebuild the pool with a live scroll position — the group-caption toggle above all (docs/vanilla-grid/22-grouping-implementation.md § 7) — depend on this.
The clamp half matters as much as the preservation: when the new projection is shorter than the surviving offset (collapsing a group while scrolled past where the collapsed projection ends), the parked heights cap the surface at the new maximum, so the offset clamps once, to a real position, instead of leaving renderVisibleRows() addressing render entries past the end of the array.
1.5 Engine-Scoped Virtual DOM Height Cap
Beyond a few million px most browsers start losing sub-pixel precision when painting sticky / monospace cells at deep scroll positions (visible glyph ghosting / row overlap) — but the safe ceiling is engine-specific, not universal:
- Gecko (Firefox) is where this artifact was originally measured, starting past ~6M px — kept at a conservative 4M px cap.
- Blink (Chrome, Edge — Edge has been Chromium since 2020) and WebKit (Safari, and every iOS browser regardless of app name) tolerate far larger scroll surfaces in practice (their internal layout clamps are in the tens of millions of px) — raised to 16M px.
MAX_VIRTUAL_DOM_HEIGHT (rendering.feature.js) resolves to one of these two constants once at load time via _IS_GECKO_ENGINE, a capability check (CSS.supports('-moz-appearance', 'none')), not a UA/brand string check — it resolves the actual engine, can't be spoofed by a UA override, and correctly treats every Chromium-based browser and every WebKit shell identically regardless of brand.
When totalRows × rowHeight exceeds this cap, the grid enters scaled mode: the DOM scroll surface is capped at MAX_VIRTUAL_DOM_HEIGHT and a scrollFactor (computed by _getVirtualMetrics()) translates each DOM pixel of viewport.scrollTop into scrollFactor logical pixels of row content. Below the cap, the grid runs in natural mode (scrollFactor = 1) — the compositor scrolls real DOM offsets directly, with zero main-thread involvement per scroll event. Raising the cap for Blink/WebKit is what lets far larger datasets run in natural mode on those engines, which matters because scaled mode has a structural cost described next.
Known outstanding verification: the manual real-device pass this raised cap calls for (iOS Safari, Android Chrome/Edge, a fractionally-scaled Windows touch display — checking scrollTop isn't clamped, no sub-pixel row drift at deep positions, rubber-band/overscroll-glow behavior) has not been performed. Only the automated boundary behavior (mode selection at the cap, scroll-to-bottom lands on the true last row) is covered, by tests/playwright/scaled-mode-boundary.spec.js. If a real-device pass ever surfaces a problem on an engine, the fix is to lower that engine's constant — this is per-engine data, not a code change.
1.6 Spacer Height Arithmetic
Natural mode — spacers simulate the height of all off-screen rows directly:
// _updateSpacerHeights(startIndex, endIndex)
topSpacerCell.style.height = startIndex * rowHeight + 'px';
bottomSpacerCell.style.height = (totalRows - endIndex) * rowHeight + 'px';
From the browser's perspective total table height equals:
topSpacer + poolRows × rowHeight + bottomSpacer
= startIndex×rh + poolSize×rh + (totalRows - endIndex)×rh
= totalRows × rowHeight (always)
This keeps the native scrollbar geometry stable and prevents layout-thrash from table height changes during scroll.
Scaled mode — the inner scroll surface is fixed at vm.domHeight (the capped height), which is smaller than the true logical content height. The rendered <tr> block must be positioned so the logical row currently scrolled to the top of the viewport actually paints at DOM offset == viewport.scrollTop:
firstVisibleRow = floor(logicalScrollTop / rowHeight)
rowsAboveInPool = max(0, firstVisibleRow - startIndex)
topSpacer.height = domScrollTop - rowsAboveInPool * rowHeight // clamped to [0, domHeight - visibleHeight]
bottomSpacer.height = domHeight - topSpacer.height - visibleHeight
Because each DOM pixel of scroll represents scrollFactor logical pixels, this re-anchor must run on every scroll event even when the rendered row window hasn't changed (the short-circuit branch in §1.7 below still calls _updateSpacerHeights in scaled mode) — the pool's DOM content is unchanged, so it's a cheap two-style-writes operation, but it's still main-thread work the compositor can't do on its own. This is the structural reason scaled mode can't fully keep up with fast touch momentum the way natural mode does.
1.7 Render Pass — renderVisibleRows(force)
Called on every scroll event, this is the hot path for virtualization. It follows a strict read-then-write pattern to avoid forced reflows:
Phase 1 — Batch read (all layout reads happen upfront):
const scrollTop = viewport.scrollTop;
const viewportHeight = _getViewportHeight(); // cached
const scrollHeight = viewport.scrollHeight;
const totalRows = renderEntries.length; // == displayRows.length unless grouped
const rowHeight = this.rowHeight;
_getVirtualMetrics() (§1.5) translates scrollTop into logicalScrollTop, the identity in natural mode or scrollTop × scrollFactor in scaled mode.
Phase 2 — Compute visible range (directional buffer skew applied, see §1.3):
startIndex = max(0, floor(logicalScrollTop / rowHeight) - startBufferRows)
endIndex = min(totalRows, ceil((logicalScrollTop + viewportHeight) / rowHeight) + endBufferRows)
// near-bottom override: extend endIndex to totalRows
Phase 3 — Short-circuit check (skip if rendered range is close enough):
if (!force && !isNearBottom) {
if (|startIndex - renderedStart| < 3 && |endIndex - renderedEnd| < 3) {
if (isScaled) _updateSpacerHeights(renderedStart, renderedEnd); // re-anchor only
return;
}
}
Phase 4 — Rotate, then write (spacers + pool rows):
_rotatePool(startIndex); // see §1.8 — physically repositions rows that overlap the new window
_updateSpacerHeights(startIndex, endIndex);
// Natural mode, or a force render: populate the whole pool synchronously.
// Scaled mode (non-force): populate only the on-screen subset synchronously,
// defer the (currently off-screen) buffer-row subset to a scheduler write —
// see §1.6 for why scaled-mode passes are costlier and benefit most from
// this split.
for (let i = 0; i < poolLength; i++) {
const rowEl = poolRows[i];
const entryIdx = startIndex + i;
rowEl.style.display = i >= clampedVisibleCount ? 'none' : '';
// Change-detection key: (entryIdx, projectionVersion), not the data
// index alone — see "Change Detection in the Render Pool" in
// ./22-grouping-implementation.md for why grouping needs this and
// captions omit dataset.vnGridRowIndex entirely. For an ungrouped grid
// this degrades to the same entry-index comparison §1.8 always used.
}
Rows beyond the visible range simply get display: none — their DOM elements stay in the table untouched.
1.8 Pool Rotation (Delta Rendering)
Before Phase 4 populates cells, _rotatePool(newStartIndex) reorders the pool so unchanged rows are recognized as unchanged.
Pool position i always represents dataIndex = startIndex + i. Historically the <tr> at DOM/array position i never moved, so any shift of startIndex changed every row's dataIndex — a 5-row scroll re-rendered the whole pool, because the population loop's reuse check (rowChanged = force || rowEl.dataset.vnGridRowIndex !== String(dataIndex)) came out true for every row.
When the new window overlaps the previous one by less than the full pool (0 < |delta| < poolLength, where delta = newStartIndex - renderedStart), most rows are still needed — they just need to occupy a different pool position, not different data. _rotatePool physically moves the |delta| <tr> elements that scrolled out of view from one end of the pool block to the other (insertBefore — cheap row reordering inside the same <tbody>, no new elements, no innerHTML) and reorders the poolRows array to match. Once that invariant holds, the existing rowChanged check does the rest with no changes to it: rows that didn't move keep the dataset.vnGridRowIndex they already had, which now (correctly) matches their new dataIndex, so they're skipped; only the rotated-in rows have a stale attribute and get repopulated. A 5-row scroll now re-renders 5 rows, not the whole pool.
No-ops (falling back to the historical full-repopulate-via-mismatch behavior) when there's no previous render to rotate from (renderedStart === -1 — reset by _invalidatePool() and by a full pool rebuild in initVirtualPool(), since a brand-new poolRows array has nothing valid to rotate), the shift is zero, or the shift is so large the two windows don't overlap at all.
1.9 Placeholder-Stripe Backstop
The spacer cells (.vn-grid-row-spacer-cell) and the viewport element itself carry a repeating-linear-gradient background keyed to --vn-grid-row-height, instead of a flat color. Real pool rows paint their own opaque theme background above it, so the stripe is only ever visible through a spacer cell or a genuine gap — any region the row pool hasn't caught up to yet (or that the compositor has scrolled to before content is rasterized) reads as "rows that haven't loaded" instead of a flat white/void flash.
Colors are theme-overridable via two custom properties, declared on .vn-grid-virtual-list-viewport instead of a flat background:
--vn-grid-placeholder-gap— the base color between stripe lines--vn-grid-placeholder-stripe— the divider-line color
The viewport's gradient uses background-attachment: local so it scrolls with the table content, keeping its phase aligned with real row dividers (automatic in natural mode, since spacer heights are always exact rowHeight multiples). The spacer cells paint the same gradient directly on themselves (inheriting the two tokens from the viewport ancestor) rather than relying solely on the viewport's own background layer, since a spacer cell's own box is what pins the pattern's phase to its own top edge.
Empty-state exception: showEmpty() (features/rendering.feature.js, called from setRows() when there are no rows to display) replaces the whole body with a single centered .vn-grid-empty-message row that never fills the viewport height — unlike a genuine loading gap (§1.15), there is no pool catching up behind it, so the backstop reads as a series of phantom empty rows instead of "not loaded yet". .vn-grid-virtual-list-viewport:has(.vn-grid-empty-message) drops just the background-image (keeping the flat --vn-grid-placeholder-gap tone, which is already each theme's own viewport background) so the area below the message reads as one clean blank space. The generic row-hover rule (.vn-grid-virtual-list-viewport tbody tr:not(.vn-grid-row-selected):hover, vanilla-grid.css) is similarly scoped with :not(:has(.vn-grid-empty-message)), since the message row holds no data and a hover tint on it reads as a glitch, not selection feedback.
1.10 Touch-Aware Synchronous Rendering
handleScroll() normally defers small scroll deltas to the next requestAnimationFrame write (§1.11) — but that's tuned against wheel/scrollbar input, where per-event deltas are bounded (normalizeWheelDelta clamps to ~1.1 viewports). Touch momentum is different: a flick sustains thousands of px/s, meaning 50-130px per frame, well under the synchronous-render threshold — precisely the input mode with the least virtualization headroom is also the one paying an extra frame of latency on every event.
Passive touchstart/touchmove listeners on the viewport (handleTouchStart/handleTouchMove, observation only — no preventDefault, no change to native touch scrolling) stamp _lastTouchAt on every touch contact. handleScroll() treats a touch gesture or its momentum tail as active whenever Date.now() - _lastTouchAt < 1500 (TOUCH_GESTURE_SYNC_WINDOW_MS, interaction.feature.js) and renders synchronously in that window, exactly like the large-delta path. touchend is intentionally not listened for — momentum continues well after the finger lifts, so "seen recently" is the right signal, not "finger currently down".
1.11 Scroll Throttling
Scroll events fire far more frequently than the monitor can display frames. VanillaGrid uses several strategies to avoid redundant renders:
| Scroll type | Strategy |
|---|---|
Large delta (> 10 × rowHeight) |
Render synchronously — data has moved far enough that the pool is stale |
| Near-bottom detection | Render synchronously — ensures the last rows appear without flicker |
| Touch gesture recently observed (§1.10) | Render synchronously — removes the extra rAF frame of latency from the input mode that can least afford it |
| Small delta, no recent touch | Throttle via requestAnimationFrame; if a frame is already pending (_scrollRafId), skip |
The rAF coalesces rapid successive scroll events into a single render per animation frame, keeping scroll at 60fps without burning CPU.
1.12 Viewport Row Snapping
When snapViewportToRows is true (default), the viewport height is calculated and explicitly set to a multiple of rowHeight:
availableHeight = containerHeight - headerHeight - groupBarHeight
snappedHeight = floor(availableHeight / rowHeight) * rowHeight
viewport.style.height = snappedHeight + 'px';
This prevents the last visible row from being partially clipped and keeps the border alignment pixel-perfect.
Every piece of container chrome must be subtracted, not just the header. .vn-grid-table-container is a column flex box, and each flex: 0 0 auto child above the viewport takes its height off the viewport's share. There are two: the header spacer, and the group bar the grouping feature mounts before it (22 § 18.6). groupBarHeight is 0 while the bar is hidden — the ungrouped case — and grows when its chips wrap to a second line at narrow widths.
The viewport must never be pinned taller than its wrapper. An explicit height on a flex item overrides align-items: stretch, so an over-large snapped value does not shrink back: the viewport simply overflows .vn-grid-scrollbar-wrapper and the container's overflow: hidden clips the excess. Omitting the group bar from the sum clipped a strip just under one row tall, so the last row of a grouped grid was scrolled to and never seen, while _getViewportHeight() handed the inflated clientHeight on to the scrollbar metrics, page-up/page-down stepping, and the scroll indicator. With layout.customScrollbar: false there is no wrapper and flex-shrink pulls the viewport back, so nothing is clipped — but the height is then no longer a multiple of rowHeight and the snap has silently stopped working. The stale value is wrong in both configurations.
The bar is registered with the grid's ResizeObserver (_observeGroupBar()), because neither the viewport (pinned to an inline height) nor the container (sized by the host) changes size when the bar appears, disappears, or re-wraps — and on the interactive path a group change reaches _applyViewportRowSnap() only through setRows(), which a regroup does not call.
A bar-driven resize must not move the scroll position. _onViewportResized() snaps a scroll offset inside the near-top band (rowHeight × (bufferRows + 1)) back to 0, which is right for a window or container resize and wrong for a group change: grouping a grid scrolled a little would jump it to row 0. When the bar is among the ResizeObserver entries, the handler is called with preserveScrollTop and skips that reset. The flag is latched, not read per call, because re-snapping the viewport resizes the viewport, which re-enters the handler in a later observer batch naming only the viewport; it is released by the same 180ms settle timer that ends the resize, i.e. once the whole cascade the bar started has gone quiet.
1.12.1 A resize during a load re-paints the skeleton, never rows
_onViewportResized() renders in two places — immediately, when the height change is big enough that the pool needs resizing, and again from the 180 ms settle timer. Both used to call renderVisibleRows(true) unconditionally, and both therefore drew real rows over a raised loading skeleton whenever a resize landed inside a load. The rows they drew were the pre-load ones, so a grid that was still working looked finished and wrong for the rest of the load, with nothing on screen saying otherwise.
Grouping a large grid hit this every single time: mounting the group bar resizes the viewport (§ 1.12) inside the reorder's own shimmer, so the skeleton raised at ~1 ms was gone at ~25 ms and never came back until the reorder landed (Performance Analysis § 6.4). It is not a grouping defect, though — a window resize, a host flex container settling, a theme swap that regrows the header, or the bar re-wrapping during any fetch all did the same.
Both call sites now go through _renderSkeletonsInsteadOfRowsWhileLoading(): while isLoading is true it calls showLoadingSkeletons() and reports that it drew, and the caller skips its row render. showLoadingSkeletons() re-reads the viewport height and the column count on every call, so re-calling it is the correct response to a resize — the skeleton simply comes back at the new size.
The geometry work is deliberately not skipped. Only the choice of what to draw changes: the row snap, the cached height, the scrollbar layout and the pool rebuild all still run, so a load that completes after the resize renders into correctly sized rows. The same idiom already existed in the empty-rows branch of _applyColumnVisibilityAndRefresh() (columns-visibility.feature.js) — "a load is in flight, draw the skeleton" is now one rule rather than two.
1.13 Viewport Height Resolution (_getViewportHeight)
Viewport height is resolved through a layered fallback path so virtualization can continue even during transient layout phases:
// simplified behavior
if (viewport.clientHeight > 0) {
_cachedViewportHeight = viewport.clientHeight;
return viewport.clientHeight;
}
return _cachedViewportHeight || 400;
Key points:
- Primary source is live
viewport.clientHeight. - A cached value (
_cachedViewportHeight) is updated whenever a valid live height exists. - If the viewport temporarily reports
0(for example during mount/reflow), virtualization falls back to the cached value. - Final hard fallback is
400so row-window math never receives an invalid height.
The same resolved height is reused by:
- Pool sizing (
visibleRows = ceil(viewportHeight / rowHeight)) - Visible-range calculation (
startIndex/endIndex) - Scrollbar metrics and thumb math
- Skeleton row count while loading
1.14 Row Height Auto-Measurement
After the first full render, measureActualRowHeight() reads a live pool row's getBoundingClientRect().height. If the measured value differs from rowHeight by more than 1px (CSS line-height, padding, or border-collapse can cause this), rowHeight is updated (and the CSS var + viewport re-snapped) grid-wide. This happens once and is then stable.
The sample row is deliberately never a group caption (.vn-grid-row-caption — see 22 § 7): a caption is structurally different (a toggle <button> instead of ordinary cell content) and its first-paint height can transiently differ from a plain data row's, most visibly right after a reload that restores an already-grouped state, where the very first visible pool row is often a caption. Sampling one would overwrite rowHeight grid-wide with a value that has nothing to do with ordinary row content.
Measuring against the baseline, not against the last measurement
rowHeight is a correction of a baseline value, and _baseRowHeight holds that baseline: the explicit layout.rowHeight when the host supplied one, otherwise the active theme's declared --vn-grid-row-height (re-captured on every theme swap). The distinction matters because .vn-grid-body-table td takes its height from the CSS variable, and in table layout that is a minimum — a row can grow past it but never below it. So a measurement taken while a previous correction is applied simply reads that correction back out of the DOM: the value can ratchet up, and no later measurement can ever bring it down.
measureActualRowHeight({ fromBaseline: true }) therefore writes _baseRowHeight into the variable first, so the row collapses onto the baseline and the measurement reads the content's own demand. The result is idempotent and recovers in both directions. The plain measureActualRowHeight() call on the render hot path deliberately skips that: the extra variable write costs a second layout pass, and a render pass only needs to catch content that outgrew the current value.
Both stylesheets must be applied before measuring
The base stylesheet (vanilla-grid.css) carries the rules a row's height actually depends on — td { line-height: 1.1; padding: …; height: var(--vn-grid-row-height) }. Measured before it applies, a row reports the height the UA's default line-height: normal produces for its tallest cell content, which is both too large and dependent on the platform's fonts.
_injectBaseStylesheet() appends that link before the theme link, but the two are independent requests and the base sheet is several times the size of any theme file, so it regularly resolves second — Firefox loses this race far more often than Chromium. The post-load resync in _updateThemeStylesheet() is consequently gated on both: _vnGridWaitForThemeLink(id, () => _vnGridWaitForBaseStylesheet(…)). A failed base stylesheet resolves its waiters too, so a missing file degrades rather than deadlocks.
A first render that beats the base stylesheet still measures unstyled rows and latches a too-tall value — that is unavoidable and briefly visible. The gated resync then re-derives fromBaseline and corrects it, which is precisely the case a plain re-measurement could not fix. Covered by tests/playwright/theme-load-row-height-race.spec.js.
1.15 Loading Skeletons
While data is being fetched, showLoadingSkeletons() replaces the pool with shimmer skeleton rows:
- Calls
_invalidatePool()— clears all pool references. - Calculates how many skeletons are needed to fill the viewport.
- Builds them all in a
DocumentFragment. - Inserts with a single
body.appendChild(fragment).
The native viewport.overflow is set to hidden during loading to prevent scroll events from triggering renders against empty data.
1.16 Writing Fast renderCell Implementations
A column's renderCell(cell, value, row, dataIndex) runs inside the render pass's hottest loop (§1.7) — once per visible row, on every pass where that row's data actually changed (rowChanged, §1.8). A renderer that does cell.innerHTML = '' followed by rebuilding several child elements from scratch pays that cost on every such pass, even when only one small piece of the markup (e.g. a marker position or a fill percentage) actually depends on value.
Prefer the reuse pattern the built-in boolean renderer (_renderBooleanCell) already uses: check cell.firstElementChild for a marker class identifying your previously-built structure; if present, update only the parts that depend on value in place; only rebuild from scratch (cell.textContent = '' + create elements) the first time or when the expected structure isn't there. Elements whose position/size/color depend only on constants (not on value) should be created once and never touched again.
renderCell(cell, value) {
let el = cell.firstElementChild;
if (!el || !el.classList.contains('my-widget')) {
cell.textContent = '';
el = document.createElement('span');
el.className = 'my-widget';
// …build any value-independent child structure once here…
cell.appendChild(el);
}
// …update only the value-dependent parts of `el` here…
}
grid-minimal-js's rating (star fill) and height (gauge marker) custom renderers follow this pattern.
1.17 Search-Match Highlighting
highlightSearchMatches (grid-wide option, off by default) wraps every occurrence of the active search term inside a visible cell's rendered text in <mark class="vn-grid-search-match">. It targets only the plain-text branch of the render pass — the col.key === '__rowNumber__', __vgSelection__, boolean-cell, and renderCell-owned branches are untouched, since a renderCell column owns its own DOM (see §1.16) and highlighting inside it is out of scope.
Hoisted once per renderVisibleRows() pass, not per cell. The lower-cased needle and a capturing, case-insensitive, global RegExp (built by escaping the term's regex metacharacters) are computed alongside the other pass-level consts (formatValue, renderEntries, §1.7) and reused for every cell in that pass. When highlighting is off or no search term is active, both are null/'' and the per-cell branch degrades to a single falsy check — the same cost as before this feature existed.
Per-cell, _renderHighlightedCellText(cell, text, needle, regex) does a cheap text.toLowerCase().includes(needle) pre-check first (mirroring StaticDataManager's own matching style) before doing any DOM-node construction; most cells in a matched row don't themselves contain the term, so the common case never pays more than the pre-existing cell.textContent = text write. Only on an actual hit does it split the text on the capturing regex and rebuild the cell's children as a mix of text nodes and <mark> elements — a full rebuild of the cell's children, not an incremental-update reuse pattern, but this adds no new re-render trigger: the plain-text branch already replaces cell.textContent wholesale on every pass where rowChanged (§1.8), so this is extra work only within a pass that was already rebuilding the cell from scratch, and only on cells that actually match.
Per-column opt-out: column.highlightSearchMatches === false skips highlighting for that column even when the grid-wide flag is on (e.g. an id/numeric column where a coincidental substring match is noise) — checked per cell, since it's a per-column, not per-pass, property.
Decoupled from DataManager matching semantics, and works identically for every data source including infinite scroll. The needle/regex pair is computed purely from the active search term and the cell's already-formatted display text — never from row-inclusion metadata, which no DataManager reliably exposes (ODataDataManager/GraphQLDataManager server responses carry no "which field matched" signal at all). Rows fetched via infinite-scroll "load more" pages populate pool slots through the exact same render path as an initial load, so newly arrived rows are highlighted on arrival with no special-casing. See docs/vanilla-grid/03-data-manager-implementation.md for the resulting "row matched, no cell highlighted" limitation.
2.1 Why Custom Scrollbars?
Native browser scrollbars:
- Cannot be positioned outside the scrollable element.
- Have inconsistent sizing and styling across operating systems.
- Cannot easily display a position indicator tooltip while dragging.
- Cannot be hidden without losing scrollability.
VanillaGrid hides the browser's native scrollbars (via overflow: hidden / -ms-overflow-style: none / scrollbar-width: none CSS) and replaces them with JS-driven elements that are absolutely positioned alongside the viewport.
2.2 DOM Layout After Init
_initCustomScrollbar() wraps the viewport in a vn-grid-scrollbar-wrapper div and appends four sibling elements:
<div class="vn-grid-scrollbar-wrapper">
<div id="viewport" class="vn-grid-custom-scroll">…</div>
<div class="vn-grid-scrollbar-track"> ← vertical track
<div class="vn-grid-scrollbar-thumb"></div>
</div>
<div class="vn-grid-scrollbar-track vn-grid-scrollbar-track-horizontal"> ← horizontal track
<div class="vn-grid-scrollbar-thumb vn-grid-scrollbar-thumb-horizontal"></div>
</div>
<div class="vn-grid-scrollbar-corner"></div> ← fills the 14×14px gap at BR corner
<div class="vn-grid-scroll-indicator"></div> ← row range label during drag
</div>
The wrapper uses position: relative so the tracks can use position: absolute to hug the right and bottom edges. This is entirely in-flow and does not affect the surrounding layout.
2.3 Thumb Size Calculation — Vertical
The thumb height is proportional to the visible fraction of the data:
// _getScrollbarMetrics()
visibleFraction = viewportHeight / totalContentHeight // totalContentHeight = rows × rowHeight
thumbHeight = max(40px, round(trackHeight × visibleFraction))
usableTrack = trackHeight - thumbHeight // maximum thumb travel distance
The 40px minimum prevents the thumb from becoming so small it is un-draggable for very large datasets.
2.4 Thumb Position on Scroll
On every scroll event _updateScrollbarThumb() maps the current scrollTop to a thumb offset using a simple linear proportion:
fraction = scrollTop / maxScrollTop // 0..1
thumbTop = round(fraction × usableTrack) // 0..usableTrack
The thumb is never moved with top or margin — it uses a GPU-composited translate3d(0, thumbTop, 0) to avoid causing any layout recalculation.
2.5 Drag-to-Scroll
When the user presses down on the thumb, setPointerCapture() is called so pointermove events continue arriving even if the pointer escapes the element:
pointerdown → capture → pointermove loop → pointerup → releaseCapture
During pointermove:
dy = e.clientY - dragStartY
newThumbTop = clamp(dragStartThumbTop + dy, 0, usableTrack)
fraction = newThumbTop / usableTrack
newScrollTop = round(fraction × maxScrollTop)
// When the thumb is pinned at the bottom, extend to the actual DOM maximum so
// load-more skeleton rows (which grow scrollHeight beyond the data-only maximum)
// are scrolled into view rather than left invisible below the viewport edge.
if (newThumbTop >= usableTrack - 1) {
domMax = max(0, viewport.scrollHeight - viewportHeight)
if (domMax > newScrollTop) newScrollTop = domMax
}
viewport.scrollTop = newScrollTop
_setThumbTop(newThumbTop) // visual update via translate3d
The thumb is moved immediately via translate3d. The resulting scroll event from viewport.scrollTop causes a render pass, which then calls _updateScrollbarThumb() again — but because _scrollbarDragging is true, the thumb size is re-synced (in case new rows arrived during infinite scroll) without overriding the drag position.
Shimmer visibility during drag: When
infiniteScrollis active and the user holds the thumb at the bottom while a slow backend fetch is in progress,_showLoadMoreSkeletons()inserts skeleton<tr>rows that growviewport.scrollHeight. Without the DOM-max extension above, every subsequentpointermovewould resetscrollTopto the data-onlymaxScrollTop, pushing the skeleton rows just below the visible area and making the grid appear frozen. The extension ensures the viewport follows the actual DOM height so skeleton rows remain visible.
Mid-drag Re-anchor for Infinite Scroll
If new rows load during a drag (via appendRows), the total content height grows. Without compensation, the thumb would jump because the same scrollTop now maps to a different fraction. _reanchorDrag() re-computes the drag baseline using the current scroll position and the new metrics, so the thumb stays visually aligned and subsequent dy deltas produce correct results.
2.6 Track Click — Page Up / Page Down
Clicking the track outside the thumb scrolls by ~90% of the viewport height in the click direction:
clickY = e.clientY - trackRect.top
thumbMid = thumbTop + thumbHeight / 2
if (clickY < thumbMid) applyScrollDelta(-pageSize)
else applyScrollDelta(+pageSize)
2.7 Horizontal Scrollbar
The horizontal scrollbar works on exactly the same principles:
- Metrics use
scrollWidth / clientWidthinstead oftotalContentHeight / viewportHeight. - The thumb uses
translate3d(left, 0, 0)instead oftranslate3d(0, top, 0). - Track click pages the
scrollLeftby 90% ofclientWidth. - During horizontal drag,
syncHeaderHorizontalScroll()is called after eachpointermoveto keep the frozen-column counter-translate and the headertranslateXin sync.
2.8 Track Visibility Logic
Both tracks are shown and hidden dynamically:
// _updateScrollbarLayout()
verticalNeeded = totalContentHeight > viewportHeight
horizontalNeeded = viewport.scrollWidth > viewport.clientWidth
verticalTrack.style.display = verticalNeeded ? '' : 'none'
horizontalTrack.style.display = horizontalNeeded ? '' : 'none'
// Adjust track lengths to account for the other track's thickness (14px)
verticalTrack.style.bottom = (horizontalNeeded ? 14 : 0) + 'px'
horizontalTrack.style.right = (verticalNeeded ? 14 : 0) + 'px'
// Corner square only shown when both tracks are visible
corner.style.display = (verticalNeeded && horizontalNeeded) ? '' : 'none'
2.9 Hover Expand Animation
The scrollbars are thin at rest (vertical: 7px wide, horizontal: 6px tall) and expand on hover or drag (10px). This is driven by _applyScrollbarVisualState() which directly sets style.width / style.height on the thumb elements. CSS transition on the thumb handles the smooth animation.
The isViewportHovered flag is shared between the viewport's mouseenter/mouseleave listeners and the scrollbar track's own mouseenter/mouseleave listeners — so hovering either the viewport or the scrollbar track expands both thumbs.
2.10 Scroll Position Indicator
During a vertical drag, a vn-grid-scroll-indicator label floats next to the thumb showing the current row range:
"1,234 - 1,257 of 100,000"
It is positioned at the vertical midpoint of the thumb:
indicator.style.top = (thumbTop + thumbHeight / 2) + 'px';
The text is computed by _updateScrollIndicator() and can be fully overridden via the formatScrollIndicator constructor option.
The indicator is only visible while dragging (display: block on pointerdown, display: none on pointerup).
Row grouping keeps this counting data rows, deliberately not switched to renderEntries. logicalScrollTop / rowHeight is a position in render-entry space once grouping is active (captions included), so _updateScrollIndicator() converts both ends back into data-row terms before reporting them. Reporting render-entry positions instead would make the numerator incomparable to a row total and would jump whenever a group collapsed — see Row Grouping Implementation § 9 for the full list of geometry consumers that do switch.
The counting rule: a collapsed caption stands for the rows it hides. _countDataRowsThroughEntry(entryPosition) (rendering.feature.js) answers "how many rows of the dataset lie at or before this render entry":
| Entry at that position | Rows counted through it |
|---|---|
| a data row | _renderEntryDataIndex[p] + 1 |
| an expanded caption | entry.dataStartIndex — its rows follow, as entries of their own |
| a collapsed caption | entry.dataStartIndex + entry.count — the whole branch is behind that one line |
| a group footer | entry.dataStartIndex + entry.count — a footer closes its group, so the whole branch is behind it whether or not the branch was drawn |
firstRow = max(1, _countDataRowsThroughEntry(firstEntryPosition - 1) + 1)
lastRow = min(max(firstRow, _countDataRowsThroughEntry(lastEntryPosition)), displayedRowCount)
displayRows holds every row in group order whether or not its group is expanded, so this is a position in the dataset that collapsing can never make skip, stall, or run backwards: the label starts at row 1 at the very top and reaches its total at the very bottom no matter what is collapsed. Counting only the rows a collapsed grid materializes does neither — with one 8,330-row group collapsed at the end of a 100,000-row grid, the bottom of the grid reads 91,670, and with a collapsed group at the start the top of the grid opens at 8,336.
Each branch is an O(1) lookup (the label is recomputed on every pointermove of a drag), and with no mapping — the ungrouped case — the whole thing degenerates to entryPosition + 1, which is why an ungrouped grid's label, and a grouped grid's with nothing collapsed, are unchanged.
The span is a dataset range, not a count of visible rows. A screenful of collapsed captions legitimately covers tens of thousands of rows (1 - 94,262 of 100,000 over fifteen caption lines), because those captions represent those rows. This is the deliberate consequence of the rule above; reporting how many rows are physically on screen instead is what makes the label unable to start at 1 or reach its total. The total itself (totalRowCount ?? displayedRowCount) is unchanged, and never shrinks to the number of rows a collapse happens to leave visible. Verified by tests/playwright/grouping-scroll-indicator.spec.js.
_resolveNearestDataIndex() still exists alongside it and still backs the infinite-scroll prefetch threshold, where "which materialized row is nearest" is the right question.
2.11 Momentum Guard
The browser sometimes fires spurious scroll events after the user stops scrolling (momentum / inertia on macOS trackpads). VanillaGrid tracks these with a momentum guard:
- When the user starts or stops a programmatic scroll (
scrollTopwrite),armMomentumGuard(duration)records a deadline. - On every
scrollevent,shouldRejectUncontrolledScroll()checks whether the event arrived within the guard window and whether the delta is large enough to be momentum. - If so, the event is rejected by writing
viewport.scrollTopback to the last known value.
The guard is released immediately if isWheelActive, isKeyActive, or _scrollbarDragging are true, so it never interferes with genuine user input.
2.12 Wheel Input Handling
handleWheel() calls e.preventDefault() to fully own wheel events (prevents the browser adding its own momentum). The delta is normalised across wheel modes:
deltaMode |
Normalisation |
|---|---|
0 (pixels) |
Use as-is |
1 (lines) |
Multiply by rowHeight |
2 (pages) |
Multiply by viewportHeight |
A minimum step of 1.25 × rowHeight is enforced so very small touchpad nudges still move at least one row. The normalised value is then multiplied by scrollSpeedMultiplier.
2.13 Teardown
_destroyCustomScrollbar() (called from destroy()):
- Removes all
pointermove/pointerupdocument listeners. - Unwraps the viewport from
vn-grid-scrollbar-wrapperback to its original parent. - Removes the wrapper from the DOM.
- Nulls all element references.
This leaves no memory leaks and no orphaned DOM nodes.
3. Interaction Between Virtual Scroll and Custom Scrollbar
The two systems are deliberately decoupled but communicate through viewport.scrollTop and viewport.scrollLeft:
User drags thumb
→ _onScrollbarThumbPointerMove()
→ viewport.scrollTop = newScrollTop ← the handoff point
→ fires "scroll" event
→ handleScroll()
→ renderVisibleRows() ← virtualization reacts
→ _updateScrollbarThumb() ← scrollbar syncs back
The grid never directly calls renderVisibleRows() from scrollbar code, and never directly updates the scrollbar from virtualization code. Both systems observe the single source of truth: viewport.scrollTop.
Because renderVisibleRows() never updates the scrollbar, every caller that changes the row count without scrolling must finish with its own _updateScrollbarThumb(): setRows() (_finishSetRows()), refresh(), the column-visibility refresh, viewport resize (_onViewportResized()), and the grouping feature's in-place re-render (_rerenderInPlace() — Expand all, Collapse all, a single-group toggle, and the grouping reorder pipeline's settle; 22 §§ 4 and 6). A caller that leaves it out produces a thumb sized for the previous row count until the next scroll event, and a drag started on that stale thumb maps its starting offset against the new track and can jump the grid the wrong way.
The only exception is the _setThumbTop() call inside _onScrollbarThumbPointerMove() — this is an optimistic visual update that moves the thumb immediately, ahead of the scroll event. Without it, the thumb would lag by one frame.
4. Configuration Reference
| Option | Default | Effect on these systems |
|---|---|---|
rowHeight |
24 |
Spacer height arithmetic, pool size, scroll-to-row conversions |
bufferRows |
20 (40 on pointer: coarse devices) |
Pool oversizing above and below visible window; skewed toward scroll direction by recent velocity (§1.3) |
snapViewportToRows |
true |
Whether viewport height is quantized to rowHeight multiples |
customScrollbar |
true |
Set false to keep the native scrollbar and skip all custom scrollbar initialisation |
scrollSpeedMultiplier |
1.35 |
Wheel delta multiplier applied after normalisation |
formatScrollIndicator |
null |
Custom function to override the drag indicator text |
formatInteger |
Intl | Formats row counts in the scroll indicator |
messages.of |
'of' |
Separator in the default indicator text "1 - 50 of 1000" |
CSS custom properties (theme-overridable, see §1.9):
| Property | Default | Effect |
|---|---|---|
--vn-grid-placeholder-gap |
#fafafa |
Placeholder-stripe base color (between divider lines) |
--vn-grid-placeholder-stripe |
#e0e0e0 |
Placeholder-stripe divider-line color |