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

    Safe Tenant Isolation in Laravel Multi Database Apps

    Safe Tenant Isolation in Laravel Multi Database Apps

    Building Reliable LLM Fallback Chains with Python

    Building Reliable LLM Fallback Chains with Python

    Profiling Python Memory Usage with Memray

    Profiling Python Memory Usage with Memray

    Fixing Cascading Renders Through React State Colocation

    Fixing Cascading Renders Through React State Colocation

    Next.js Custom Image Loader for S3 and MinIO

    Next.js Custom Image Loader for S3 and MinIO

    React Form State with Server Actions

    React Form State with Server Actions

    Fixing Livewire State Loss During Network Drops

    Fixing Livewire State Loss During Network Drops

    Handling Concurrent Writes with Laravel Pessimistic Locking

    Handling Concurrent Writes with Laravel Pessimistic Locking

    Hardening a Linux VPS with Custom Port and Keys

    Hardening a Linux VPS with Custom Port and Keys

    Next.js Server Actions Concurrency and Stale Data

    Next.js Server Actions Concurrency and Stale Data