DEV Community

kohii
kohii

Posted on

How to Implement Virtual Scrolling Beyond the Browser's Limit

Introduction

I am currently developing a CSV editor called SmoothCSV 3. It uses the framework Tauri, and for the renderer, I employ web technologies (React + TypeScript).

CSV editors need to display a large number of rows and cells. In web technologies, virtual scrolling is the standard approach for such scenarios. SmoothCSV also utilized virtual scrolling, but I encountered a problem where extremely large datasets, such as those with millions of rows, could not be fully displayed due to the limitations of virtual scrolling.

In this article, I will introduce the basics of virtual scrolling and explain how I overcame its limitations.

Why Do We Need Virtual Scrolling?

Ordinary Scrolling

When displaying lists or tables with a very large number of items in a browser, a naive implementation will suffer performance degradation as the number of items increases, eventually causing the browser to freeze. This is because the browser constructs and renders the DOM tree for all items, including those outside the viewport.

Ordinary Scrolling

Virtual Scrolling as a Solution

To solve this problem, virtual scrolling is often used. Virtual scrolling is a technique where only the items visible based on the scroll position are dynamically generated using JavaScript, thereby reducing the load on the browser.

Virtual Scrolling

The visible area is called the viewport, and the content outside the viewport is called the buffer. To enable proper scrolling in the browser, dummy elements (spacers) are placed in the buffer areas to maintain the height of the content.

Spacer

(Alternatively, you can specify the height of the content container and position the displayed items using position: absolute.)

How to Implement Virtual Scrolling

There are many libraries available to easily implement virtual scrolling, and usually, you would use these. In React, the following are well-known:

I personally prefer using TanStack Virtual, which also supports Vue, Svelte, Solid, Lit, and Angular besides React.

The Limits of Virtual Scrolling

It's not widely known, but there is an upper limit to the width and height of elements that can be displayed in a browser. The values I measured are as follows:

  • Safari: 33,554,428px
  • Chromium: 16,777,216px

Elements with height or width larger than this will be truncated to the maximum value. (Properties like top, left, margin, and padding also have such upper limits.)

<!-- Example in Safari -->
<!-- Truncated to 33,554,428px -->
<div style="height: 9999999999999px;"></div>

<!-- Same even without directly specifying the size -->
<!-- Should total 40,000,000px, but truncated to 33,554,428px -->
<div>
  <div style="height: 20000000px"></div>
  <div style="height: 20000000px"></div>
</div>

Enter fullscreen mode Exit fullscreen mode

Virtual scrolling is also affected by this limitation. If the height of the scrollable content (i.e., the total height of all items) exceeds this upper limit, you cannot scroll to the end even with virtual scrolling.

Limitation

In my CSV editor, since the height per row is 22px, the limit is reached at around 1,525,200 rows on macOS (Safari), resulting in the inability to display rows beyond that point.

While this number of rows might not be a common consideration, CSV files can have a very large number of rows. Accepting this limitation would diminish the value of a CSV editor.

Strategies to Overcome the Limits

Virtual scrolling relies on the browser for the scrolling mechanism itself, so the height of the scrollable content, including the buffer, must be the same as the actual total height of all rows. In other words, as long as we depend on the browser's scrolling functionality, we are bound by this limitation.

So, let's create our own scrolling mechanism!

Solution

(it might even be better to render using canvas.)

Implementing Beyond the Limits

Let's try implementing this in React.

1. Creating a Scrollbar

First, we'll create a scrollbar by combining div elements. Generally, the rail of the scrollbar is called the Track, and the thumb is called the Thumb.

ScrollBar

The ScrollBar component receives properties such as the viewport height (viewportSize), the height of the content to be scrolled (contentSize), and the current scroll position (scrollPosition), and renders a custom scrollbar. It notifies the result of user interactions via a callback (onScroll).

Sizes

The implementation is roughly as follows. It is a stateless, controlled component.

export type ScrollBarProps = {
  viewportSize: number; // Size of the viewport
  contentSize: number; // Total size of the content
  scrollPosition: number; // Scroll position
  onScroll?: (scrollPosition: number) => void; // Callback when scroll position changes
};

export function ScrollBar({
  contentSize,
  viewportSize,
  scrollPosition,
  onScroll,
}: ScrollBarProps) {
  // Calculate the size and position of the scrollbar from contentSize, viewportSize, and scrollPosition
  const scrollRatio = viewportSize / contentSize;
  const thumbSize = Math.max(
    16, // Minimum size of the thumb (to prevent it from becoming too small)
    scrollRatio * viewportSize
  );
  const maxScrollPosition = contentSize - viewportSize;
  const thumbPosition =
    (scrollPosition / maxScrollPosition) * (viewportSize - thumbSize);

  // Do not display the scrollbar if the content fits within the viewport
  const scrollBarVisible = contentSize > viewportSize;

  // Convert the thumb position back to the actual scroll position
  const translateToScrollPosition = (thumbPosition: number) => {
    const newPosition =
      (thumbPosition / (viewportSize - thumbSize)) * maxScrollPosition;
    return Math.min(maxScrollPosition, Math.max(0, newPosition));
  };

  // Handler for when the thumb is grabbed and dragged
  const handleMouseDownOnThumb = (event: React.MouseEvent) => {
    if (!scrollBarVisible) return;
    event.preventDefault();
    event.stopPropagation();

    const startMousePosition = event.clientY;
    const startThumbPosition = thumbPosition;

    // Handler for mouse movement while holding down the button
    // (Register the event on the document to capture movement anywhere)
    const handleMouseMove = (event: MouseEvent) => {
      const delta = event.clientY - startMousePosition;
      onScroll?.(translateToScrollPosition(startThumbPosition + delta));
    };

    const handleMouseUp = () => {
      document.removeEventListener('mousemove', handleMouseMove);
      document.removeEventListener('mouseup', handleMouseUp);
    };
    document.addEventListener('mousemove', handleMouseMove);
    document.addEventListener('mouseup', handleMouseUp);
  };

  const handleMouseDownOnTrack = (event: React.MouseEvent) => {
    // Handler for when the track outside the thumb is clicked
    // Option 1: Move the thumb to the clicked position
    // Option 2: Move one page in the clicked direction
  };

  return (
    <div
      style={{
        position: 'relative',
        height: viewportSize,
        width: 14,
      }}
      onMouseDown={handleMouseDownOnTrack}
    >
      {scrollBarVisible && (
        <div
          onMouseDown={handleMouseDownOnThumb}
          style={{
            position: 'absolute',
            top: thumbPosition,
            height: thumbSize,
            width: 8,
            borderRadius: 8,
            margin: '0 3px',
            background: 'rgba(0, 0, 0, 0.5)',
          }}
        />
      )}
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

Note that you need to handle more aspects to consider accessibility and behavior on mobile devices.

2. Creating a Scroll Pane

Next, we'll create a component that provides state management for the scrollbar and a viewport that displays content synchronized with it.
(This component provides only the scrolling mechanism, and the content to be rendered based on the scroll position is received via children.)

ScrollPane

import type React from 'react';
import { useState } from 'react';
import { ScrollBar } from './ScrollBar';

export type ScrollPaneProps = {
  contentSize: number;
  viewportSize: number;
  // Receives a function as a child that returns content to display based on the scroll position
  // (Function as a Child (FaCC) pattern)
  children: (scrollPosition: number) => React.ReactNode;
};

export function ScrollPane({
  children,
  contentSize,
  viewportSize,
}: ScrollPaneProps) {
  const [scrollPosition, setScrollPosition] = useState(0);

  const handleWheel = (event: React.WheelEvent) => {
    // Handler for scrolling with mouse wheel
    setScrollPosition((prev) => {
      const newScrollPosition = prev + event.deltaY;
      return Math.max(
        0,
        Math.min(contentSize - viewportSize, newScrollPosition)
      );
    });
  };

  return (
    <div style={{ display: 'flex', height: viewportSize }} onWheel={handleWheel}>
      {/* Viewport */}
      <div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
        {children(scrollPosition)}
      </div>
      <ScrollBar
        contentSize={contentSize}
        viewportSize={viewportSize}
        scrollPosition={scrollPosition}
        onScroll={setScrollPosition}
      />
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

Using this component looks like this; you pass the content you want to display in the ScrollPane as a function.

// Here, we're simply displaying the scroll position
export default function App() {
  return (
    <ScrollPane
      contentSize={1000}
      viewportSize={300}
      style={{ border: '1px solid #ddd' }}
    >
      {(scrollPosition) => <div>scrollPosition: {scrollPosition}</div>}
    </ScrollPane>
  );
}

Enter fullscreen mode Exit fullscreen mode

Test ScrollPane

3. Rendering the Content

Finally, we render the content we want to display based on the scroll position. Here, we'll show an example of displaying a list of items with uniform height.

const ITEM_HEIGHT = 30;

// Generate a list of 3 million items
const items = Array.from({ length: 3000000 }, (_, i) => `Item ${i}`);

// 3 million * 30px = 90,000,000px (> browser limit)
const totalHeight = ITEM_HEIGHT * items.length;
const viewportSize = 300;

export default function App() {
  return (
    <ScrollPane contentSize={totalHeight} viewportSize={viewportSize}>
      {(scrollPosition) => {
        // Calculate the items to display within the viewport
        const startIndex = Math.floor(scrollPosition / ITEM_HEIGHT);
        const endIndex = Math.min(
          Math.ceil((scrollPosition + viewportSize) / ITEM_HEIGHT) + 1,
          items.length
        );
        const visibleItems = items.slice(startIndex, endIndex);

        // Logical position of the first visible item
        const startPosition = startIndex * ITEM_HEIGHT;

        return (
          <div
            style={{
              position: 'absolute',
              top: startPosition - scrollPosition, // Distance from the top of the viewport
            }}
          >
            {visibleItems.map((item) => (
              <div key={item} style={{ height: ITEM_HEIGHT }}>
                {item}
              </div>
            ))}
          </div>
        );
      }}
    </ScrollPane>
  );
}

Enter fullscreen mode Exit fullscreen mode

And with that, we're done! 🎉

Done

We adjust the display position of the items by specifying the distance from the top of the viewport using top. For items towards the end, startPosition becomes very large, but since scrollPosition is also large, the value passed to top remains within a practical range.

Here's a cleaner version of the completed code (using TailwindCSS):

https://github.com/kohii/react-custom-scroll-example

Conclusion

Thank you for reading to the end 🙇‍♂️

Please give SmoothCSV a try if you're interested.

https://github.com/kohii/smoothcsv3

Top comments (0)