Tech Verse Logo
Enable dark mode
React List Virtualization and Accessibility

React List Virtualization and Accessibility

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

4 min read

If you build log viewers or data tables in Next.js 16, you've probably watched a page grind to a halt when rendering five thousand rows. React 19 handles fast component updates well, but it can't alter how browser layout engines calculate geometry. Every HTML element costs memory, style recalculation time, and paint effort. When your DOM node count passes a few thousand, scrolling stutters and input updates lag behind.

When standard rendering breaks down

A standard map operation in React works cleanly for 50 items. At 500 complex items—say, table rows containing avatar images, dropdown menus, and dynamic badges—you quickly hit 15,000 DOM nodes. At 5,000 items, you reach 150,000 nodes. Browser memory usage climbs past 400MB for the DOM structure alone, and layout recalculation times jump from 4ms to over 120ms per frame.

List virtualization (also called windowing) fixes this by keeping only visible items inside the DOM tree. If your container frame shows 15 rows, you render around 20 items—adding a small buffer above and below the viewport. As the user scrolls, top rows unmount and bottom rows take their place. Your node count stays fixed at 20 regardless of whether the dataset contains 100 items or 100,000 items.

Measuring the threshold: When windowing pays off

Don't add virtual list libraries by default. Windowing introduces real code complexity around focus state, browser text search, and variable heights. Base your decision on performance profiles rather than assumptions:

  • Under 100 simple items: Never virtualize. Direct DOM rendering is simpler and faster to initialize.
  • 100 to 500 items: Virtualize only if each list row contains heavily nested elements or component state.
  • 500+ items: Virtualize by default, especially on mobile browsers where constrained memory triggers aggressive garbage collection.

Building a virtual list in React 19

The standard tool for modern React applications is @tanstack/react-virtual version 3. It decouples windowing logic from DOM styling, letting you render head-less client components inside Next.js 16 App Router structures. Here is a baseline virtualized log output list:

"use client";

import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

interface LogEntry {
  id: string;
  timestamp: string;
  message: string;
}

export function VirtualizedLogList({ logs }: { logs: LogEntry[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const rowVirtualizer = useVirtualizer({
    count: logs.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 35,
    overscan: 5,
  });

  return (
    <div
      ref={parentRef}
      style={{ height: "400px", overflow: "auto", border: "1px solid #ccc" }}
    >
      <div
        style={{
          height: `${rowVirtualizer.getTotalSize()}px`,
          width: "100%",
          position: "relative",
        }}
      >
        {rowVirtualizer.getVirtualItems().map((virtualRow) => {
          const item = logs[virtualRow.index];
          return (
            <div
              key={virtualRow.key}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                height: `${virtualRow.size}px`,
                transform: `translateY(${virtualRow.start}px)`,
              }}
            >
              <strong>{item.timestamp}</strong>: {item.message}
            </div>
          );
        })}
      </div>
    </div>
  );
}

This implementation runs smooth 60fps scrolling on low-power hardware. But out of the box, it breaks screen reader behavior and keyboard controls.

The accessibility trap: Focus loss and screen readers

When you strip unrendered DOM elements from the tree, you break three core accessibility features:

  1. Total size context: Screen readers rely on DOM structure to announce total item counts (for instance, "Item 4 of 5000"). If only 20 elements exist in the tree, VoiceOver announces "Item 4 of 20".
  2. Focus destruction: If a user focuses an interactive element inside item #12 and scrolls down, item #12 unmounts. The focused node disappears from the DOM, throwing active focus back to the <body> element. The keyboard user loses their position instantly.
  3. Browser search (Ctrl+F): Off-screen items don't exist in the HTML layout, so native browser search yields zero results for hidden rows.

Fixing ARIA role counts and focus retention

To keep screen reader context intact, expose dataset dimensions using explicit grid roles paired with aria-rowcount and aria-rowindex. You must also maintain focused indices in state so focus gets restored when off-screen items scroll back into view.

Here is an accessible, virtualized data grid built for Next.js 16 client components:

"use client";

import { useRef, useState, KeyboardEvent } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

interface AccountRecord {
  id: string;
  label: string;
}

export function AccessibleVirtualList({ items }: { items: AccountRecord[] }) {
  const parentRef = useRef<HTMLDivElement>(null);
  const [focusedIndex, setFocusedIndex] = useState<number | null>(null);

  const rowVirtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48,
    overscan: 3,
  });

  const handleKeyDown = (e: KeyboardEvent, index: number) => {
    if (e.key === "ArrowDown") {
      e.preventDefault();
      const nextIndex = Math.min(index + 1, items.length - 1);
      rowVirtualizer.scrollToIndex(nextIndex);
      setFocusedIndex(nextIndex);
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      const prevIndex = Math.max(index - 1, 0);
      rowVirtualizer.scrollToIndex(prevIndex);
      setFocusedIndex(prevIndex);
    }
  };

  return (
    <div
      ref={parentRef}
      tabIndex={0}
      role="grid"
      aria-rowcount={items.length}
      aria-label="User account directory"
      style={{ height: "320px", overflow: "auto", border: "1px solid #ccc" }}
    >
      <div
        style={{
          height: `${rowVirtualizer.getTotalSize()}px`,
          width: "100%",
          position: "relative",
        }}
        role="presentation"
      >
        {rowVirtualizer.getVirtualItems().map((virtualRow) => {
          const item = items[virtualRow.index];
          const isFocused = focusedIndex === virtualRow.index;

          return (
            <div
              key={virtualRow.key}
              role="row"
              aria-rowindex={virtualRow.index + 1}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                height: `${virtualRow.size}px`,
                transform: `translateY(${virtualRow.start}px)`,
              }}
            >
              <div
                role="gridcell"
                tabIndex={isFocused ? 0 : -1}
                onFocus={() => setFocusedIndex(virtualRow.index)}
                onKeyDown={(e) => handleKeyDown(e, virtualRow.index)}
                style={{
                  outline: isFocused ? "2px solid #2563eb" : "none",
                  padding: "12px",
                  boxSizing: "border-box",
                  height: "100%",
                }}
              >
                {item.label}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

Handling text search without performance loss

Because hidden elements aren't in the DOM, native Ctrl+F won't match off-screen rows. You have two options to address this issue:

First, implement a dedicated search filter input component above your list. When the user types, filter the array in memory before passing it to useVirtualizer. The hook recalibrates total scroll heights while maintaining focus boundaries.

Second, modern browsers support the hidden="until-found" attribute alongside the beforematch event. However, rendering thousands of items with hidden attributes defeats the memory benefits of windowing. For lists larger than 1,000 items, stick with explicit memory-side string filtering inputs.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    LLM API Pricing Comparison for Side Projects

    LLM API Pricing Comparison for Side Projects

    Building Semantic Search with Laravel 12 and Next.js 16

    Building Semantic Search with Laravel 12 and Next.js 16

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Wiring LLMs with Tool Function Calling

    Wiring LLMs with Tool Function Calling

    LLM API Rate Limit Caching and Quota Guarding

    LLM API Rate Limit Caching and Quota Guarding

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Pgvector Similarity Search in Production Postgres

    Pgvector Similarity Search in Production Postgres

    React Drag and Drop with dnd-kit and Laravel

    React Drag and Drop with dnd-kit and Laravel

    Building a Production RAG Pipeline in PHP 8.3

    Building a Production RAG Pipeline in PHP 8.3

    Laravel LLM Integration: Queues and Real-Time Frontend UI

    Laravel LLM Integration: Queues and Real-Time Frontend UI