# 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:

```javascript
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

![Reverse Infinite Scroll in react using TanStack Virtual | by Rahul  Moghariya | Medium](https://miro.medium.com/v2/resize:fit:1400/1*814PZGqmFkIxq-_UNNY6QA.png align="left")

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`:

```javascript
// 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:

```javascript
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:

```javascript
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:

```javascript
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:

```javascript
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:

```javascript
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:

```javascript
<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](https://jspad.dev/?id=4GHi0vdXSjaltkjcxORr&o=2&c=2):

[![](https://cdn.hashnode.com/res/hashnode/image/upload/v1744530427001/45f6af83-2636-4e0a-8e67-f09faa5e862f.png align="center")](https://jspad.dev/?id=4GHi0vdXSjaltkjcxORr&o=1&c=2)

## 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:

1. Scrolling can feel a little “jumpy”.
    
2. 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.

```javascript
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:

```javascript
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:

```javascript
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](https://jspad.dev/?o=1&id=tz8HaZh9p25vygxKPAPl&c=2):

[![](https://cdn.hashnode.com/res/hashnode/image/upload/v1744530904905/e171e506-7208-423a-82bb-5ec2eb1f3032.png align="center")](https://jspad.dev/?o=1&id=tz8HaZh9p25vygxKPAPl&c=2)

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!
