Simple virtual scroll calculation
typescriptA framework-agnostic TypeScript utility that calculates which items should be rendered in a virtual scrolling list based on scroll position, container dimensions, and item metrics.
Code
interface VirtualScrollConfig {
scrollTop: number;
containerHeight: number;
itemHeight: number;
totalItems: number;
overscan?: number;
}
interface VirtualScrollResult {
startIndex: number;
endIndex: number;
offsetTop: number;
visibleCount: number;
totalHeight: number;
}
class VirtualScroller {
private config: Required<VirtualScrollConfig>;
constructor(config: VirtualScrollConfig) {
this.config = {
...config,
overscan: config.overscan ?? 3,
};
}
calculate(): VirtualScrollResult {
const { scrollTop, containerHeight, itemHeight, totalItems, overscan } = this.config;
if (totalItems === 0 || itemHeight <= 0 || containerHeight <= 0) {
return {
startIndex: 0,
endIndex: 0,
offsetTop: 0,
visibleCount: 0,
totalHeight: 0,
};
}
const totalHeight = totalItems * itemHeight;
const visibleCount = Math.ceil(containerHeight / itemHeight);
const rawStartIndex = Math.floor(scrollTop / itemHeight);
const startIndex = Math.max(0, rawStartIndex - overscan);
const rawEndIndex = rawStartIndex + visibleCount;
const endIndex = Math.min(totalItems, rawEndIndex + overscan);
const offsetTop = startIndex * itemHeight;
return {
startIndex,
endIndex,
offsetTop,
visibleCount,
totalHeight,
};
}
update(partialConfig: Partial<VirtualScrollConfig>): VirtualScrollResult {
this.config = {
...this.config,
...partialConfig,
overscan: partialConfig.overscan ?? this.config.overscan,
};
return this.calculate();
}
getItemRange(): number[] {
const { startIndex, endIndex } = this.calculate();
return Array.from({ length: endIndex - startIndex }, (_, i) => startIndex + i);
}
scrollToIndex(index: number): number {
const clampedIndex = Math.max(0, Math.min(index, this.config.totalItems - 1));
return clampedIndex * this.config.itemHeight;
}
isIndexVisible(index: number): boolean {
const { startIndex, endIndex } = this.calculate();
const actualStart = startIndex + this.config.overscan;
const actualEnd = endIndex - this.config.overscan;
return index >= actualStart && index < actualEnd;
}
}
function createVirtualScroller(config: VirtualScrollConfig): VirtualScroller {
return new VirtualScroller(config);
}
// Usage example
const scroller = createVirtualScroller({
scrollTop: 500,
containerHeight: 400,
itemHeight: 50,
totalItems: 1000,
overscan: 3,
});
const result = scroller.calculate();
console.log('Virtual scroll calculation:', result);
// Output: { startIndex: 7, endIndex: 21, offsetTop: 350, visibleCount: 8, totalHeight: 50000 }
console.log('Items to render:', scroller.getItemRange());
// Output: [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
const updatedResult = scroller.update({ scrollTop: 1000 });
console.log('After scroll update:', updatedResult);
// Output: { startIndex: 17, endIndex: 31, offsetTop: 850, visibleCount: 8, totalHeight: 50000 }
console.log('Scroll position for index 50:', scroller.scrollToIndex(50));
// Output: 2500
console.log('Is index 20 visible?', scroller.isIndexVisible(20));
// Output: true or false depending on current scroll position
export { VirtualScroller, createVirtualScroller };
export type { VirtualScrollConfig, VirtualScrollResult };How It Works
This VirtualScroller utility solves a fundamental performance problem: rendering thousands of DOM elements kills performance. Virtual scrolling renders only what's visible plus a small buffer, dramatically reducing memory usage and improving scroll performance.
The core algorithm divides the scroll position by item height to find which item should appear at the top of the viewport. The overscan parameter adds extra items above and below the visible area to prevent flickering during fast scrolling—without it, users might see blank space before new items render. The offsetTop value is crucial: it tells you how much to translate the rendered items downward so they appear in the correct position within a container that has the full totalHeight.
The class-based design with an update method allows efficient recalculation without creating new objects on every scroll event. This matters because scroll events can fire 60+ times per second. The getItemRange helper returns an array of indices you can map over to render your items, while scrollToIndex and isIndexVisible provide common navigation utilities.
Edge cases are handled carefully: zero items, zero heights, and invalid inputs all return safe defaults instead of NaN or Infinity. The clamping logic ensures startIndex never goes negative and endIndex never exceeds totalItems. Note this implementation assumes fixed-height items—variable height virtualization requires a more complex approach with measured or estimated heights stored in a lookup structure.
Use this pattern for lists exceeding ~100 items where scroll performance matters. Avoid it for short lists where the overhead isn't justified, or when items have complex mount/unmount lifecycles that make frequent rendering expensive. For production use, consider adding debouncing for scroll handlers and requestAnimationFrame batching for smoother updates.