Implementing virtual scroll for web from scratch, in less than 150 lines of code


What is virtual scroll ?
Virtual scroll (also called virtualization or windowing) is a technique used to efficiently render large lists in web applications — without loading everything into the DOM at once. Instead of rendering 1,000+ items, you only render what's visible in the viewport, plus a small buffer above and below for smooth scrolling.
As you scroll, items outside the viewport are removed from the DOM, and new ones are added on-demand. Let’s see how we would implement it from scratch. We’ll would use ReactJS to create a reusable component in this post, but these principles can be applied to any framework or even vanilla JS.
Step 1: prepare data for scroll
First off, let’s get the preparatory step out of the way. It’s not directly related to the idea of virtual scroll, but we’ll need it done to test our implementation later. Here’s a simple snippet that we can use to generate a huge list with random data:
const ARR_SIZE = 500_000;
const arr = new Array(ARR_SIZE).fill(null).map((_, i) => ({
index: i + 1,
id: Math.random(),
value: Math.floor(Math.random() * 2 * ARR_SIZE),
}));
We’ll create an array with 500,000 fake rows. Each one having:
an index (like 1, 2, 3…)
a random ID
a random value (just to show data)
Step 2: the core idea
The core idea can be understood by the diagram above.
We provide a fixed size container to the user, to scroll things within it. Let’s call that windowHeight
. Each row/item is also given a fixed size, say rowHeight
. This means we can have atmost windowHeight/rowHight
items shown in the container, let’s call that WINDOW_SIZE
.
As the user starts scrolling the container, we keep track of the height that has scrolled up (container.scrollTop
value). We can use this value along with rowHeight
to calculate the index at which container should start showing the items. Similarly, we can use windowHeight
to calculate the index at which container should stop showing the items.
As the user keeps scrolling, we keep recalculating the start and end index for the items to be shown and re-render those in the UI.
An interesting thing to note here is the scroll position. Simply rendering the items from start to end index would reset the scroll position, thus not allowing the user to scroll anymore, so we need some way for maintaining desired scroll position. A simple way to achieve this would be to slap two divs
, one at top of the container and another at bottom, each having height equal to expected scroll offsets at top and bottom. We’ll see how to implement this shortly.
This pretty much is the crux of the solution. Let’s get our hands dirty with some code now.
Step 3 - basic implementation
Lets create a component VirtualTable
:
// windowSize is the number of rows to be shown in the window.
// rowHeight is the fixed height of each row.
// arr contains the huge data to be shown in virtualTable.
function VirtualTable({ arr, windowSize, rowHeight }) {
...
}
Let’s track the scroll position as we talked earlier:
const [scrollTop, setScrollTop] = useState(0);
const handleScroll = (e) => {
e.preventDefault();
setScrollTop(e.target.scrollTop);
};
...
return (
<div onScroll={handleScroll} ...> ... </div>
);
Now let’s calculate the start and index for items to be shown within the virtual table:
const startIndex = Math.floor(scrollTop / rowHeight);
// Better, let's clamp it to 0, to ensure it never becomes negative.
const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight));
// Similar calculation for endIndex:
const endIndex = Math.min(arr.length - 1, startIndex + windowSize - 1);
Maintain list of items to be rendered:
const shownRowsIndex = [];
for (let i = startIndex; i <= endIndex; i++) {
shownRowsIndex.push(i);
}
We want the scroll bar to feel like it scrolls through the whole list, even though we’re only rendering a few items. So we fake the total height using invisible div
s.
Padding above:
const topPad = startIndex * rowHeight;
This means: if you’ve scrolled past 20 items, we add 20 × row height = 600px of empty space at the top.
Padding below:
const bottomPad = totalHeight - (renderedHeight + topPad);
This calculates how much space is left after the visible rows.
So if total height is 15,000px, and you’ve shown 30 rows and scrolled past 600px, we add just enough padding to fill in the rest.
Rendered output looks like this:
<div
onScroll={handleScroll}
style={{
height: rowHeight * windowSize,
overflowY: 'auto',
}}
>
<div style={{ height: topPad }}></div>
{shownRowsIndex.map((itemIndex) => (
<div
className="t-row"
style={{ height: rowHeight }}
key={arr[itemIndex].id}
>
row#{arr[itemIndex].index} : {arr[itemIndex].value}
</div>
))}
<div style={{ height: bottomPad }}></div>
</div>
You can see it all in action in JSPad:
Step 4 - Optimizations
So far, we’ve covered the basics of virtual scroll: Only render what’s visible.
But once that works, you start to notice two issues:
Scrolling can feel a little “jumpy”.
Updating on every pixel of scroll becomes expensive.
Let’s fix both — starting with buffering.
1. Buffer Rows — Why They Matter
Without buffer, when you scroll down, new rows only appear when they come exactly into view. This causes a harsh snapping effect — rows are constantly entering and exiting the DOM too quickly.
With buffer, we render a few extra rows above and below the viewport. So even if a row is just offscreen, it’s already in the DOM and ready to slide in. This makes scrolling feel buttery smooth.
const SCROLL_BUFFER_SIZE = Math.floor(WINDOW_SIZE / 2);
...
const startIndex = Math.floor(
(scrollTop - bufferSize * rowHeight) / rowHeight
);
const endIndex = startIndex + 2 * bufferSize + windowSize - 1;
This adds half the container size worth of rows above and below.
So if you're supposed to show rows 50–65, you might actually render 43–72, and visually clip the rest.
2. Throttling — Why We Need It
React re-renders every time scrollTop changes. And when you scroll rapidly, onScroll
can fire dozens of times per second. That’s... too much.
Without control, this leads to:
Too many state updates
Laggy performance
Unnecessary DOM churn
Solution: Throttle scroll updates- We write a small helper:
function throttle(fn, t) {
let blocked;
return (...args) => {
if (blocked) return;
blocked = true;
setTimeout(() => (blocked = false), t);
return fn.apply(this, args);
};
}
This allows us to call setScrollTop()
at most once every 100ms. In the component:
const throttledScroll = throttle(handleScroll, 100);
...
<div onScroll={throttleScroll}> ... </div>
This way, even during fast scrolling, React doesn’t panic trying to keep up.
Step 5: Putting it all together to test
You can find the final result and play around with the implementation yourself in JSPad:
This isn’t all though. We can still make this more generic and performant, there’s still ample scope for tweaks and improvements. For example, how do we account for rows with different heights?
I’ll leave this question open for now, and maybe address it in a future post!
Cheers until then!
Subscribe to my newsletter
Read articles from Anish Kumar directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
