A variable-height virtual list estimates each row's height, renders the visible window, measures real heights with a shared ResizeObserver as rows mount and whenever they change, updates a prefix-sum offset index, and adjusts scrollTop by the height change of any row above the viewport so the content the user is looking at does not move.
Problem / Scenario Context
A support inbox virtualises thousands of conversations. Rows vary in height — one-line subjects, two-line previews, rows with attachment chips — and a few lines of text can wrap differently at each window width. The first implementation assumed a fixed 72 px row height: the scrollbar lied, rows overlapped, and jumping to a conversation landed a few rows off. The second measured rows with getBoundingClientRect() after render, which fixed the layout but caused visible jumps when images inside rows loaded after the measurement and when the user scrolled up into rows whose estimates had been wrong.
Windowing with variable heights is a measurement problem. The Virtual Lists & Windowing with Observers topic covers windowing; this page covers heights.
Mechanics Explanation
A virtual list positions rows absolutely (or with a spacer) using an offset index: offset[i] = sum of heights of rows 0..i−1. The window to render is found by searching the index for the scroll position. With unknown heights:
- Estimate each row's height (a constant, or per row type).
- Render the rows in the window at their estimated offsets.
- Measure actual heights after layout.
- Correct the index: every row after a corrected row shifts by the difference.
- Compensate scroll: if the corrected row is above the current viewport, the content the user sees would shift by the difference, so add it to
scrollTopin the same frame.
ResizeObserver is the ideal measuring tool: it reports each mounted row's size right after layout — before paint — and keeps reporting whenever the row's height changes later (images decoding, fonts loading, "show more" toggles, width changes). Correction and compensation inside the callback land in the same frame, so nothing visibly jumps.
A plain array prefix sum costs O(n) per correction; a Fenwick tree (binary indexed tree) makes both correction and lookup O(log n), which matters beyond tens of thousands of rows.
Comparison Table: Height Strategies
| Strategy | Accurate scrollbar | Handles late growth | Jump risk | Cost |
|---|---|---|---|---|
| Fixed height | only if truly fixed | no | overlaps | none |
Measure once with getBoundingClientRect |
after visiting | no | on late growth | forced layout per row |
| RO measure + correct, no compensation | converges | yes | on scroll up | low |
| RO measure + correct + compensate | converges | yes | none | low |
| Pre-measure off-screen | yes | no | on late growth | high up front |
Minimal Reproducible Example
const ROW = 72; // assumed height
function render(scrollTop: number): void {
const first = Math.floor(scrollTop / ROW);
const rows = data.slice(first, first + 20);
container.replaceChildren(...rows.map((r, i) => {
const el = renderRow(r);
el.style.transform = `translateY(${(first + i) * ROW}px)`;
return el;
}));
}
declare const data: unknown[]; declare const container: HTMLElement;
declare function renderRow(r: unknown): HTMLElement;
Two-line rows overlap the next row; the scrollbar length is wrong; jumping to row 500 lands in the wrong place.
Production-Safe Solution
class Fenwick {
#t: Float64Array;
constructor(n: number, init: number) {
this.#t = new Float64Array(n + 1);
for (let i = 1; i <= n; i++) { this.#t[i] += init; const j = i + (i & -i); if (j <= n) this.#t[j] += this.#t[i]; }
}
add(i: number, delta: number): void { for (i++; i < this.#t.length; i += i & -i) this.#t[i] += delta; }
prefix(i: number): number { let s = 0; for (; i > 0; i -= i & -i) s += this.#t[i]; return s; } // sum of rows < i
/** Largest i with prefix(i) <= y — the row at scroll position y. */
find(y: number): number {
let pos = 0, rem = y, step = 1 << Math.floor(Math.log2(this.#t.length - 1 || 1));
for (; step; step >>= 1) if (pos + step < this.#t.length && this.#t[pos + step] <= rem) { pos += step; rem -= this.#t[pos]; }
return pos;
}
}
export class VariableList {
#heights: Float64Array;
#index: Fenwick;
#rowOf = new WeakMap<Element, number>();
#ro: ResizeObserver;
constructor(private scroller: HTMLElement, private spacer: HTMLElement, count: number, private estimate = 72) {
this.#heights = new Float64Array(count).fill(estimate);
this.#index = new Fenwick(count, estimate);
this.spacer.style.blockSize = `${count * estimate}px`;
this.#ro = new ResizeObserver((entries) => {
const viewTop = this.scroller.scrollTop; // one read, layout is clean here
let above = 0, total = 0;
for (const e of entries) {
const i = this.#rowOf.get(e.target);
if (i === undefined) continue;
const h = e.borderBoxSize[0].blockSize;
const delta = h - this.#heights[i];
if (Math.abs(delta) < 0.5) continue;
this.#heights[i] = h;
this.#index.add(i, delta);
total += delta;
if (this.#index.prefix(i) + h - delta <= viewTop) above += delta; // row ended above the view
}
if (!total) return;
this.spacer.style.blockSize = `${this.#index.prefix(count)}px`;
if (above) this.scroller.scrollTop = viewTop + above; // keep visible content still
this.layoutMounted();
});
}
mount(el: HTMLElement, i: number): void { this.#rowOf.set(el, i); this.#ro.observe(el, { box: 'border-box' }); }
unmount(el: HTMLElement): void { this.#ro.unobserve(el); this.#rowOf.delete(el); }
offsetOf(i: number): number { return this.#index.prefix(i); }
rowAt(y: number): number { return this.#index.find(y); }
layoutMounted(): void { /* re-apply translateY(offsetOf(i)) to mounted rows */ }
}
Rows are observed as they mount and unobserved as they unmount, so the observer only ever tracks the window. Every height change — first measurement, image decode, font swap, width-driven re-wrap — arrives through the same callback, before paint. The callback corrects the Fenwick tree, resizes the spacer, compensates scrollTop for rows that ended above the viewport, and re-positions mounted rows, all in one frame.
Choosing the Estimate
Better estimates mean smaller corrections and a more truthful scrollbar before rows are visited:
- Per type: rows with attachments, rows with two-line previews and plain rows can each have their own estimate.
- Running average: after measuring a few hundred rows, use their mean as the estimate for unmeasured rows, and rescale the spacer. The scrollbar thumb then settles quickly.
- Server hints: if the backend knows text length, a cheap character-count heuristic predicts wrapping well for a known font and width.
- Width changes: when the list's width changes, all heights may change; keep the measured heights (they are corrected as rows re-render) but re-estimate unmeasured ones from the new width.
Jump-to-row accuracy depends on the offset index: with the Fenwick tree, offsetOf(i) for an unvisited row uses estimates, so jumping lands close, and the correction pass after mount snaps it exactly — compensating so the target row stays at the top.
Verification Steps
- Scroll up from deep in the list; rows above being measured must not move the visible rows.
- Load images slowly in rows above the viewport and confirm no jumps.
- Resize the window so rows re-wrap and confirm the list re-settles without overlap.
- Jump to a row index and confirm it ends up exactly at the top after measurement.
- Heap snapshot: the observer tracks only mounted rows.
Common Mistakes to Avoid
- Measuring with
getBoundingClientRectafter render. It forces layout per row and misses later growth. - Correcting without compensating. Rows above the viewport move the content under the reader.
- Observing unmounted rows. Unobserve on unmount, or the observer retains detached nodes.
- An O(n) offset update per correction on very long lists.
FAQ
Why border-box rather than content-box?
Rows are stacked by their full height, including padding and border. borderBoxSize gives exactly the space each row occupies in the list.
Why is it safe to set scrollTop inside the ResizeObserver callback?
Scrolling does not change the observed rows' sizes, so it cannot re-trigger the observer. Doing it in the callback, before paint, is what keeps the adjustment invisible.
Does overflow-anchor make compensation unnecessary?
Scroll anchoring can handle some cases, but virtual lists position rows absolutely, which anchoring does not track reliably. Explicit compensation is predictable; disable anchoring on the scroller to avoid double adjustment.
Is a Fenwick tree overkill?
For a few thousand rows, a plain prefix-sum array updated lazily is fine. Beyond tens of thousands, O(log n) updates and lookups keep every correction and every scroll lookup cheap.
How do I handle rows that change height while visible?
The same way: the observer reports the change, the index is corrected, and rows below shift. Only rows above the viewport need scroll compensation.
What about horizontal virtual lists?
Use inlineSize instead of blockSize and scrollLeft instead of scrollTop; the structure is identical.
Related
- Building a Windowed Virtual List with IntersectionObserver — the windowing side
- Sentinel-Based Windowing for Chat Logs — variable heights in a chat
- Bidirectional Infinite Scroll: Loading Upward — the same compensation for prepends
↑ Back to Virtual Lists & Windowing with Observers