# 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`
- **APG Landmark Regions:** <https://www.w3.org/WAI/ARIA/apg/practices/landmark-regions/>
- **Page:** <https://ui.booleanpress.com/components/virtual-scroller> · @booleanpress/ui 0.2.0

## 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`.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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

| Key | Behaviour |
| --- | --- |
| Tab | Moves 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 Down or Page Up | Scroll by a view: the browser's own scrolling. |
| Home or End | Scroll to the first or the last item, through the virtualiser, so measured items land exactly. |

## API

### VirtualScroller

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` (required) | `readonly T[]` |  | Every item; only those in view are rendered. |
| `renderItem` (required) | `(item: T, index: number) => ReactNode` |  | Draws one item. It is wrapped in an element that places it, sized `itemSize` along the scroll. |
| `columns` | `number` | `3` | The number of items in a grid row. |
| `empty` | `ReactNode` |  | What shows when there are no items and nothing is loading. Defaults to the provider's `noItems` string. |
| `estimatedItemSize` | `number` | `40` | The size assumed for an item until it is measured, without `itemSize`. |
| `gap` | `number` | `0` | The 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. |
| `hasMore` | `boolean` | `false` | More items exist after the last one: `onLoadMore` is called as the end comes into view. |
| `itemSize` | `number` |  | Each item's size along the scroll in pixels (the row's height in a grid). Leave it out to measure every item. |
| `list` | `boolean` | `true` | Marks 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. |
| `loaderCount` | `number` | `3` | The number of placeholder rows while loading. |
| `loading` | `boolean` | `false` | Items are loading: placeholder rows follow the last item and the region is marked busy. |
| `loadMoreThreshold` | `number` | `5` | How 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"` | `vertical` | `vertical` (default) scrolls a column, `horizontal` a row, `grid` rows of `columns` items. |
| `overscan` | `number` | `5` | How 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`.

| Token | Used for |
| --- | --- |
| `--muted-foreground` | text |
| `--ring` | outline |
