Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Virtualising long lists in React

Turtlekit Editorial Team

On the day the accounts close, an accountant opens a customer’s ledger: every payment, refund and fee since the account was opened, newest first. For a customer of eight years that is ten thousand lines. The page froze long enough for her to click “Ledger” a second time, and on her phone the tab reloaded itself.

The ledger rendered every entry as a row in the document, all of them before the first paint, although the screen shows about ten at a time. Nearly all of that work was for rows nobody would scroll to.

Virtualisation renders only the rows in view, plus a few either side, and lets the rest stand as empty space of the right height. The document stays small however long the list grows, and the scrollbar still says how far there is to go.

Ten thousand rows nobody sees

The ledger as it shipped:

// Avoid: ten thousand entries, ten thousand rows, before the first
// paint
export function Ledger({
  entries,
}: {
  entries: readonly LedgerEntry[];
}) {
  return (
    <ul aria-label="Ledger">
      {entries.map((entry) => (
        <li key={entry.id}>
          <LedgerRow entry={entry} />
        </li>
      ))}
    </ul>
  );
}

Each entry becomes a list item holding three spans. The browser builds, styles and lays out tens of thousands of elements before it can paint, and React keeps its own record of each one. The test at the end mounts this version with ten thousand entries and finds ten thousand list items in the document.

The cost grows with the data rather than the screen. A list that was fine with a year of entries isn’t with eight, and nothing in the component changed in between.

A window over a long strip

Picture the ledger printed on one long strip of paper, and the screen as a window cut in a card held over it. Scrolling slides the strip under the window. Virtualisation prints only the part of the strip under the window, and a little beyond its edges; the rest is blank paper of the right length, so the scrollbar still measures the whole.

Not in the documentOVERSCANIn viewOVERSCANNot in the document
The list keeps the height of every row, so the scrollbar measures the whole ledger; only the rows in view and a few either side are in the document.

The document holds what people can see; the data holds everything. The rest of the work is keeping the two in step as the window moves, and telling assistive technology about the rows the document leaves out.

Building a window over the ledger

The window needs three things: which rows are in view, a list as tall as every row together, and each row placed where it would have been.

Working out which rows are in view

// Which rows the list renders: the ones in view, and a few either
// side so a quick scroll never shows a gap.
interface Viewport {
  scrollTop: number;
  viewportHeight: number;
  rowHeight: number;
  count: number;
  overscan: number;
}

/**
 * The first row to render, and the one after the last (as `slice`
 * takes them).
 */
export function visibleRange({
  scrollTop,
  viewportHeight,
  rowHeight,
  count,
  overscan,
}: Viewport) {
  const first = Math.floor(scrollTop / rowHeight);
  const last = Math.ceil((scrollTop + viewportHeight) / rowHeight);
  return {
    start: Math.max(0, first - overscan),
    end: Math.min(count, last + overscan),
  };
}

With every row the same height, the first row in view is the scroll offset divided by the row height, and the last is the bottom of the view divided the same way. overscan adds rows either side, so a quick scroll meets rows that are ready instead of a gap. Trace it at the top: scrolled to 0, in a 480 px view of 48 px rows, rows 0 to 9 are in view, and the range covers rows 0 to 13: ten in view and four below.

A list as tall as the whole ledger

const [scrollTop, setScrollTop] = useState(0);
const { start, end } = visibleRange({
  scrollTop,
  viewportHeight: height,
  rowHeight: ROW_HEIGHT,
  count: entries.length,
  overscan: OVERSCAN,
});

The component keeps the scroll offset in state and updates it on each scroll event, then works out the range during render. The list itself is given the height of every entry together, so the scrollbar is as long as it would be with all of them. Each rendered row is placed at the offset it would have had.

Telling assistive technology where a row sits

A screen reader counts the items in a list to say “3 of 14”, and with fourteen in the document that would be wrong. So each row carries aria-setsize, the whole ledger’s length, and aria-posinset, its place in it. The list keeps role="list", because Safari drops a list’s meaning once its bullets are styled away. And the scrolling region is named and focusable, so keyboard users can move into it and scroll it with the arrow keys and Page Down.

The finished ledger

"use client";
// The ledger, virtualised: the document holds the rows in view and a
// few either side; a list the height of every row keeps the scrollbar
// true.
import { useId, useState } from "react";
import { LedgerRow } from "./ledger-row";
import { visibleRange } from "./visible-range";
import type { LedgerEntry } from "./ledger";

const ROW_HEIGHT = 48;
const OVERSCAN = 4;

interface LedgerListProps {
  entries: readonly LedgerEntry[];
  /** The scrolling area's height, in pixels. */
  height?: number;
}

export function LedgerList({
  entries,
  height = 480,
}: LedgerListProps) {
  const id = useId();
  const [scrollTop, setScrollTop] = useState(0);
  const { start, end } = visibleRange({
    scrollTop,
    viewportHeight: height,
    rowHeight: ROW_HEIGHT,
    count: entries.length,
    overscan: OVERSCAN,
  });

  return (
    <>
      <h2 id={`${id}-title`}>Ledger</h2>
      {/* Named and focusable: arrows and Page Down scroll it. */}
      <div
        role="region"
        aria-labelledby={`${id}-title`}
        tabIndex={0}
        onScroll={(event) =>
          setScrollTop(event.currentTarget.scrollTop)
        }
        style={{ height, overflowY: "auto" }}
      >
        {/* role="list": a list without bullets keeps its meaning. */}
        <ul
          role="list"
          style={{
            position: "relative",
            height: entries.length * ROW_HEIGHT,
            margin: 0,
            padding: 0,
            listStyle: "none",
          }}
        >
          {entries.slice(start, end).map((entry, i) => (
            <li
              key={entry.id}
              aria-setsize={entries.length}
              aria-posinset={start + i + 1}
              style={{
                position: "absolute",
                insetInline: 0,
                top: (start + i) * ROW_HEIGHT,
                height: ROW_HEIGHT,
              }}
            >
              <LedgerRow entry={entry} />
            </li>
          ))}
        </ul>
      </div>
    </>
  );
}

Open a ledger of ten thousand entries and fourteen rows are in the document. Scroll to the middle and the rows around entry 5,000 take their place, with the scrollbar where it would be if every row were there.

What the window hides

Virtualisation removes rows from the document, and the document is what the browser’s own features read. Each of these costs is real, and each has an answer.

Measuring the gain

The tests count list items, before and after:

import {
  fireEvent,
  render,
  screen,
  within,
} from "@testing-library/react";
import { expect, test } from "vitest";
import * as before from "./ledger-before";
import { LedgerList } from "./ledger-list";
import { visibleRange } from "./visible-range";
import type { LedgerEntry } from "./ledger";

const ENTRIES: LedgerEntry[] = Array.from(
  { length: 10_000 },
  (_, i) => ({
    id: `txn_${String(i + 1)}`,
    date: `${(i % 28) + 1} Oct 2026`,
    description: `Payment for INV-${String(10_000 + i)}`,
    amount: "€240.00",
  }),
);

test("ten thousand entries mount one window of rows", () => {
  render(<LedgerList entries={ENTRIES} />);
  const ledger = screen.getByRole("region", { name: "Ledger" });
  expect(ledger).toHaveAttribute("tabindex", "0");
  const rows = within(ledger).getAllByRole("listitem");
  // Ten rows fill 480px at 48px each; four more wait below.
  expect(rows).toHaveLength(14);
  expect(rows[0]).toHaveAttribute("aria-posinset", "1");
  expect(rows[0]).toHaveAttribute("aria-setsize", "10000");
  expect(within(ledger).getByRole("list").style.height).toBe(
    "480000px",
  );
});

test("scrolling moves the window; the rows keep their places", () => {
  render(<LedgerList entries={ENTRIES} />);
  const ledger = screen.getByRole("region", { name: "Ledger" });
  fireEvent.scroll(ledger, { target: { scrollTop: 5000 * 48 } });
  const rows = within(ledger).getAllByRole("listitem");
  expect(rows).toHaveLength(18);
  expect(rows[0]).toHaveAttribute("aria-posinset", "4997");
  expect(
    within(ledger).getByText("Payment for INV-15000"),
  ).toBeInTheDocument();
  expect(
    within(ledger).queryByText("Payment for INV-10000"),
  ).toBeNull();
});

test("the window ends with the list", () => {
  expect(
    visibleRange({
      scrollTop: 9990 * 48,
      viewportHeight: 480,
      rowHeight: 48,
      count: 10_000,
      overscan: 4,
    }),
  ).toEqual({ start: 9986, end: 10_000 });
});

test(
  "the list before the change mounts every entry",
  { timeout: 30_000 },
  () => {
    render(<before.Ledger entries={ENTRIES} />);
    expect(screen.getAllByRole("listitem")).toHaveLength(10_000);
  },
);

The first version puts ten thousand rows in the document. The window puts fourteen at the top and eighteen in the middle: four either side of the ten in view, each with its position in the whole. To see the time saved rather than the elements, record the ledger opening in the browser’s Performance panel with the CPU slowed down, before and after.

Screen readers read the window

aria-posinset lets a screen reader say where a row is, but its reading cursor moves through the rows that exist. Whether reading on brings the next rows depends on the screen reader and the browser scrolling the region as it goes, which isn’t something to build on. Give people a direct way to any row: a search or filter over the whole ledger, and a way to jump to a date.

Find in page can’t find what isn’t there

The browser’s find searches the document, so an entry outside the window can’t be found, and an accountant looking for a payment by its reference gets no match. A virtualised list needs its own search, over the data rather than the document.

Coming back to the same place

A browser restores the page’s scroll position when people come back to it, and a single-page app’s router may do the same. Neither restores a scrolling region inside the page, which mounts again at the top. Save the first visible entry when people leave, in the history entry’s state or in session storage. Once the entries have loaded, scroll the region back to it.

The lighter alternative: content-visibility

// The lighter alternative: every row stays in the document, and the
// browser skips the layout and painting of the rows off screen until
// they come near.
import { LedgerRow } from "./ledger-row";
import type { LedgerEntry } from "./ledger";

export function Ledger({
  entries,
}: {
  entries: readonly LedgerEntry[];
}) {
  return (
    <ul aria-label="Ledger">
      {entries.map((entry) => (
        <li
          key={entry.id}
          // "auto 48px": a placeholder height until a row has been
          // seen, then the height it really had, so the scrollbar
          // stays steady.
          style={{
            contentVisibility: "auto",
            containIntrinsicSize: "auto 48px",
          }}
        >
          <LedgerRow entry={entry} />
        </li>
      ))}
    </ul>
  );
}

content-visibility: auto keeps every row in the document and lets the browser skip the layout and painting of rows away from the screen. MDN notes that the skipped rows stay in the accessibility tree, and available to find-in-page and keyboard focus, so the three costs above go away. contain-intrinsic-size gives a skipped row a placeholder height, and with auto, the height it really had once it has been seen.

It saves the browser’s rendering, not React’s: React still creates every row, and the document still holds them all. It has been Baseline since September 2024, and a browser without it renders the list as before. When the cost is layout and paint rather than React, try it first.

Rows of different heights

The arithmetic assumes every row is 48 px. Rows whose height depends on their content need an estimate before they render, a measurement after, and a correction to the scroll position as one replaces the other. That is where a maintained library such as TanStack Virtual earns its place; the window and the accessibility work stay the same.

Server rendering, React 19 and the Compiler

The server has no scroll position, so it renders the first window, and the HTML holds only those rows: a crawler or a reader without JavaScript sees the newest entries and no more. Nothing here changes between React 18 and 19. Keep the scroll update urgent; deferring it with useDeferredValue or a Transition lets the window fall behind the scrollbar, which shows as blank space. The React Compiler can make each scroll’s render cheaper, but only the window takes rows out of the document.

When the whole list should stay

Virtualise when rendering the list is measurably slow, people scroll it more than they search it, and its rows are simple to place. Short of that, render every row: a few hundred plain rows are cheap, and content-visibility carries a list much further.

When people need to find, link to and print places in the list, pages serve them better. Each page is a whole document that every browser feature reads, and its address can be shared. A printed ledger, in particular, holds only what is in the document.

How Turtlekit’s DataTable keeps a long ledger light

Turtlekit’s DataTable takes the second road: it renders one page of rows at a time, with Pagination in its footer, so the document stays small and every row in it is real. The page comes from the address, and the total goes to paging:

// The ledger with Turtlekit: a DataTable that shows one page of
// entries at a time. The page number comes from the address
// (?page=3), and each page link navigates, so every page can be
// shared, reloaded and come back to.
import { DataTable } from "turtlekit";
import type { LedgerEntry } from "./ledger";

const PAGE_SIZE = 50;

interface LedgerProps {
  entries: readonly LedgerEntry[];
  /** From the address's ?page=, read by the route. */
  page: number;
}

export function Ledger({ entries, page }: LedgerProps) {
  return (
    <DataTable
      caption="Ledger"
      rows={entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)}
      getRowId={(entry) => entry.id}
      getRowLabel={(entry) => `${entry.date}, ${entry.description}`}
      columns={[
        { key: "date", header: "Date" },
        { key: "description", header: "Description" },
        { key: "amount", header: "Amount", numeric: true },
      ]}
      paging={{
        page,
        pageSize: PAGE_SIZE,
        total: entries.length,
        noun: "entries",
        hrefFor: (p) => `?page=${p}`,
      }}
    />
  );
}

Each page is a whole table that screen readers, find-in-page and printing read in full, and because the page number comes from the address and each page link navigates, every page can be shared, reloaded and come back to. The footer says which entries are showing.

When a virtual list misbehaves

Blank rows flash during a fast scroll

The window renders after the scroll, and heavy rows can take longer than a frame. Raise OVERSCAN, make each row cheaper, and keep the scroll state update urgent.

The scrollbar jumps while scrolling

Rows aren’t the height the arithmetic assumes: a long description wraps, or a font arrives late and changes the line height. Fix the row height in CSS and truncate long text, with the full text available on the row, or measure the rows.

Focus disappears when a row scrolls away

A focused row that leaves the window unmounts, and focus falls back to the page. Keep the focused row rendered even outside the range, or move focus to the list’s region before its row goes.

Render what people see, and say what they don’t

Rendering every row feels honest, because nothing is hidden. It is also how a ledger that worked for years stops working without a line of its code changing. The window keeps the document the size of the screen, and the finished ledger keeps the rest of the truth in its attributes: each row says where it sits among ten thousand. What a window can’t keep is the browser’s own reading of the page, so a long list that is virtualised also needs a search of its own, and a list short enough to keep whole should stay whole.

useDeferredValue in React

When the list renders few rows, but each update to it is slow.

Loading components on demand in React

When the page is slow because it downloads too much, not renders too much.

Debouncing input in React

When a search over the whole ledger should wait for a pause in typing.

State colocation in React

When scrolling one list re-renders parts of the page that never read it.