Skip to the content

ComponentsData

Virtual scroller

A scrolling region for long lists that renders only the items in view, in a column, a row or a grid.

Import

import { VirtualScroller } from "@booleanpress/ui/virtual-scroller"

Also install @tanstack/react-virtual: pnpm add @tanstack/react-virtual

Usage

VirtualScroller renders only the items in view, and a few either side, so a list of 100,000 rows scrolls as smoothly as one of 10. It is built on TanStack Virtual; install the peer first: npm install @tanstack/react-virtual.

import { VirtualScroller } from "@booleanpress/ui/virtual-scroller"

export function DeliveryLog({ deliveries }: { deliveries: { id: number; to: string }[] }) {
  return (
    <VirtualScroller
      aria-label="Delivery log"
      items={deliveries}
      itemSize={40}
      getItemKey={(delivery) => delivery.id}
      className="h-80 rounded-sm border"
      renderItem={(delivery) => <div className="flex h-full items-center px-2 text-sm">{delivery.to}</div>}
    />
  )
}

Give it a height (a width for orientation="horizontal") and a name with aria-label or aria-labelledby. itemSize fixes every item's size along the scroll, which is the fastest; leave it out and each item is measured as it renders, starting from estimatedItemSize. orientation="grid" lays the items out columns to a row, itemSize then being the row's height; gap spaces them. overscan renders that many more items beyond each edge. Pass getItemKey when items can be added or removed, so each keeps its element.

For an endless list, set hasMore while more items exist and onLoadMore to fetch them: it is called once the last items come into view, and loading shows placeholder rows meanwhile. A ref gives scrollToIndex(index, { align }), scrollToOffset(offset) and the scrolling element.

The items are a list by default: each carries its aria-posinset and the list's aria-setsize (โˆ’1 while more can load), so a screen reader knows the whole length though only a part is in the page; list={false} drops the list roles. The scroller draws no frame or surface of its own: give it a border, a radius and a background with className. For a list people choose from, use Listbox or a virtualised Combobox; for rows with columns, DataTable with virtual.

Examples

Basic

100,000 rows of 50px in a 200px region; only about ten are in the page at any time.

import { VirtualScroller } from "@booleanpress/ui/virtual-scroller"

const number = new Intl.NumberFormat("en-US")
const DELIVERIES = Array.from({ length: 100_000 }, (_, index) => ({ id: index + 1, to: `customer${(index * 7919) % 5000}@example.com` }))

export default function VirtualScrollerBasic() {
  return (
    <VirtualScroller
      aria-label="Delivery log, 100,000 messages"
      items={DELIVERIES}
      itemSize={50}
      getItemKey={(delivery) => delivery.id}
      className="h-50 w-full max-w-xs rounded-sm border"
      renderItem={(delivery, index) => (
        <div className={`flex h-full flex-col justify-center p-2 text-sm ${index % 2 ? "bg-muted" : ""}`}>
          <span>Message #{number.format(delivery.id)}</span>
          <span className="truncate text-xs text-muted-foreground">{delivery.to}</span>
        </div>
      )}
    />
  )
}

Horizontal

orientation="horizontal" scrolls a row of 744 hours sideways, mirrored in a right-to-left page.

import { VirtualScroller } from "@booleanpress/ui/virtual-scroller"

// Every hour of October 2026, with a made-up send count.
const HOURS = Array.from({ length: 31 * 24 }, (_, index) => ({
  label: `Oct ${Math.floor(index / 24) + 1}, ${String(index % 24).padStart(2, "0")}:00`,
  sent: (index * 37) % 100,
}))

export default function VirtualScrollerHorizontal() {
  return (
    <VirtualScroller
      aria-label="Emails sent each hour in October 2026"
      orientation="horizontal"
      items={HOURS}
      itemSize={50}
      className="h-50 w-full max-w-md rounded-sm border"
      renderItem={(hour, index) => (
        <div className={`flex h-full flex-col items-center justify-end gap-2 p-2 text-sm ${index % 2 ? "bg-muted" : ""}`}>
          <span className="w-4 rounded-sm bg-primary" style={{ height: `${hour.sent}%` }} />
          <span className="text-xs whitespace-nowrap text-muted-foreground [writing-mode:vertical-lr]">{hour.label}</span>
          <span className="sr-only">{hour.sent} sent</span>
        </div>
      )}
    />
  )
}

Grid

orientation="grid" with columns={3} and gap={8}: 10,000 template cards, virtualised by row.

import { VirtualScroller } from "@booleanpress/ui/virtual-scroller"

const TEMPLATES = Array.from({ length: 10_000 }, (_, index) => ({
  id: index + 1,
  name: `Template ${index + 1}`,
  kind: ["Welcome", "Receipt", "Digest", "Reminder"][index % 4],
}))

export default function VirtualScrollerGrid() {
  return (
    <VirtualScroller
      aria-label="Email templates"
      orientation="grid"
      columns={3}
      gap={8}
      items={TEMPLATES}
      itemSize={72}
      getItemKey={(template) => template.id}
      className="h-72 w-full max-w-md rounded-sm border p-2"
      renderItem={(template) => (
        <div className="flex h-full flex-col justify-center rounded-md border bg-card px-3 text-sm">
          <span className="truncate font-medium">{template.name}</span>
          <span className="text-xs text-muted-foreground">{template.kind}</span>
        </div>
      )}
    />
  )
}

Variable heights

Without itemSize every reply is measured as it renders, so rows of one line and of four sit together.

import { VirtualScroller } from "@booleanpress/ui/virtual-scroller"

const LINES = [
  "Thanks, that fixed it.",
  "The invoice for October shows the old company address. Could you send a corrected copy to our accounts team?",
  "Can we move the call to Thursday?",
  "Our sending domain fails the DKIM check since we changed DNS provider on 2 October. We have copied the record from the dashboard twice and it still says pending. Screenshots attached.",
  "Please close this ticket.",
]
const REPLIES = Array.from({ length: 5_000 }, (_, index) => ({ id: index + 1, text: LINES[(index * 3) % LINES.length] }))

export default function VirtualScrollerVariableHeights() {
  return (
    <VirtualScroller
      aria-label="Ticket replies"
      items={REPLIES}
      estimatedItemSize={60}
      getItemKey={(reply) => reply.id}
      className="h-72 w-full max-w-sm rounded-sm border"
      renderItem={(reply, index) => (
        <div className={`flex flex-col gap-1 p-2 text-sm ${index % 2 ? "bg-muted" : ""}`}>
          <span className="text-xs text-muted-foreground">Reply {reply.id}</span>
          <span>{reply.text}</span>
        </div>
      )}
    />
  )
}

Lazy loading

hasMore, onLoadMore and loading: the next 30 subscribers arrive 800 ms after the end comes into view.

import { useEffect, useRef, useState } from "react"
import { VirtualScroller } from "@booleanpress/ui/virtual-scroller"

const PAGE = 30
const TOTAL = 150
const contact = (index: number) => ({ id: index + 1, email: `subscriber${index + 1}@example.com` })

export default function VirtualScrollerLazyLoading() {
  const [contacts, setContacts] = useState(() => Array.from({ length: PAGE }, (_, index) => contact(index)))
  const [loading, setLoading] = useState(false)
  const request = useRef<ReturnType<typeof setTimeout>>(undefined)
  // A request still on its way when the list goes away is dropped.
  useEffect(() => () => clearTimeout(request.current), [])

  // A pretend request: the next page arrives after 800 ms.
  const loadMore = () => {
    setLoading(true)
    request.current = setTimeout(() => {
      setContacts((previous) => [...previous, ...Array.from({ length: PAGE }, (_, index) => contact(previous.length + index))])
      setLoading(false)
    }, 800)
  }

  return (
    <VirtualScroller
      aria-label="Subscribers"
      items={contacts}
      itemSize={40}
      getItemKey={(subscriber) => subscriber.id}
      hasMore={contacts.length < TOTAL}
      loading={loading}
      onLoadMore={loadMore}
      className="h-60 w-full max-w-xs rounded-sm border"
      renderItem={(subscriber, index) => (
        <div className={`flex h-full items-center p-2 text-sm ${index % 2 ? "bg-muted" : ""}`}>{subscriber.email}</div>
      )}
    />
  )
}

Scroll to index

A ref gives scrollToIndex: the buttons jump to event 5,000 and back.

import { useRef } from "react"
import { Button } from "@booleanpress/ui/button"
import { VirtualScroller, type VirtualScrollerHandle } from "@booleanpress/ui/virtual-scroller"

const number = new Intl.NumberFormat("en-US")
const EVENTS = Array.from({ length: 10_000 }, (_, index) => `Webhook event ${number.format(index + 1)}`)

export default function VirtualScrollerScrollToIndex() {
  const scroller = useRef<VirtualScrollerHandle>(null)

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <div className="flex gap-2">
        <Button variant="outline" size="sm" onClick={() => scroller.current?.scrollToIndex(4_999, { align: "start" })}>
          Go to event 5,000
        </Button>
        <Button variant="outline" size="sm" onClick={() => scroller.current?.scrollToIndex(0)}>
          Back to the first
        </Button>
      </div>
      <VirtualScroller
        ref={scroller}
        aria-label="Webhook events"
        items={EVENTS}
        itemSize={40}
        className="h-50 rounded-sm border"
        renderItem={(event, index) => (
          <div className={`flex h-full items-center p-2 text-sm ${index % 2 ? "bg-muted" : ""}`}>{event}</div>
        )}
      />
    </div>
  )
}

Empty and retry

empty holds a "Try again" button; the second request returns rows and the list takes over.

import { useEffect, useRef, useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { VirtualScroller } from "@booleanpress/ui/virtual-scroller"

const contact = (index: number) => ({ id: index + 1, email: `subscriber${index + 1}@example.com` })

export default function VirtualScrollerEmptyAndRetry() {
  const [contacts, setContacts] = useState<ReturnType<typeof contact>[]>([])
  const [loading, setLoading] = useState(false)
  // The first request found nothing, so there is nothing more to ask for until the person tries again.
  const [loaded, setLoaded] = useState(false)
  const request = useRef<ReturnType<typeof setTimeout>>(undefined)
  useEffect(() => () => clearTimeout(request.current), [])

  // A pretend request: rows arrive after 800 ms.
  const retry = () => {
    setLoading(true)
    request.current = setTimeout(() => {
      setContacts(Array.from({ length: 30 }, (_, index) => contact(index)))
      setLoaded(true)
      setLoading(false)
    }, 800)
  }

  return (
    <VirtualScroller
      aria-label="Subscribers"
      items={contacts}
      itemSize={40}
      getItemKey={(subscriber) => subscriber.id}
      loading={loading}
      hasMore={false}
      empty={
        loaded ? undefined : (
          <div className="flex flex-col items-start gap-2">
            <span>No subscribers came back.</span>
            <Button variant="outline" size="sm" onClick={retry}>
              Try again
            </Button>
          </div>
        )
      }
      className="h-60 w-full max-w-xs rounded-sm border"
      renderItem={(subscriber, index) => (
        <div className={`flex h-full items-center p-2 text-sm ${index % 2 ? "bg-muted" : ""}`}>{subscriber.email}</div>
      )}
    />
  )
}

Accessibility

Semantics
The scroller is a role="region" named by aria-label or aria-labelledby. Its content is a role="list" and each rendered item a listitem with aria-posinset and aria-setsize, so the whole length is known though only a part is rendered. While loading, the region is aria-busy and a status line says "Loading moreโ€ฆ"; the placeholder rows are hidden from assistive technology.
Labels
Name the region with aria-label or aria-labelledby. The loading and empty texts ("Loading moreโ€ฆ", "Loading", "No items") come from the provider.
Focus
The region is one tab stop, so the keyboard can scroll it; focusable content inside the items follows in the tab order. A focused region is outlined with --ring.
Known limits
  • Items out of view are not in the page: the browser's find-in-page cannot reach them, and a focused control inside an item loses the focus when its item scrolls far out of view.
  • A screen reader's reading cursor moves through the rendered items only; the position and size tell where they sit in the whole list.
  • onLoadMore is called once for each length of items: after a load that adds nothing (a failed request) it is called again only when items changes or the end leaves the view and comes back. With no items at all, show your own way to try again.

Keyboard

Keyboard
KeyBehaviour
TabMoves the focus to the region, so the keys below scroll it.
โ†“orโ†‘Scroll a little down or up (ArrowLeft and ArrowRight sideways): the browser's own scrolling.
Page DownorPage UpScroll by a view: the browser's own scrolling.
HomeorEndScroll to the first or the last item, through the virtualiser, so measured items land exactly.

API

VirtualScroller

VirtualScroller props
PropTypeDefaultDescription
itemsrequiredreadonly T[]Every item; only those in view are rendered.
renderItemrequired(item: T, index: number) => ReactNodeDraws one item. It is wrapped in an element that places it, sized itemSize along the scroll.
columnsnumber3The number of items in a grid row.
emptyReactNodeWhat shows when there are no items and nothing is loading. Defaults to the provider's noItems string.
estimatedItemSizenumber40The size assumed for an item until it is measured, without itemSize.
gapnumber0The space between items (and between a grid's columns), in pixels.
getItemKey((item: T, index: number) => Key)A stable key for an item, such as its id. Defaults to its position.
hasMorebooleanfalseMore items exist after the last one: onLoadMore is called as the end comes into view.
itemSizenumberEach item's size along the scroll in pixels (the row's height in a grid). Leave it out to measure every item.
listbooleantrueMarks the items as a list: the content is role="list" and each item a listitem with its aria-posinset and the list's aria-setsize, so a screen reader knows the whole size though only a part is rendered.
loaderCountnumber3The number of placeholder rows while loading.
loadingbooleanfalseItems are loading: placeholder rows follow the last item and the region is marked busy.
loadMoreThresholdnumber5How many items before the end onLoadMore is called.
onLoadMore(() => void)Called once the last items come into view while hasMore is set and nothing is loading.
orientation"grid" | "horizontal" | "vertical"verticalvertical (default) scrolls a column, horizontal a row, grid rows of columns items.
overscannumber5How many items to render beyond each edge of the view, so a fast scroll shows no gap.
renderLoader((index: number) => ReactNode)Draws a placeholder row while loading, in place of the built-in skeleton.

Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.

Data attributes: data-slot="virtual-scroller" (VirtualScroller), and data-orientation.

Provider strings: noItems, loadingMore, loading (BooleanUIProvider's strings).

Theming

The scroller has no surface of its own; style its frame with className. The loading rows use Skeleton, and the empty text --muted-foreground.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--muted-foregroundtext
--ringoutline