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 byaria-labeloraria-labelledby. Its content is arole="list"and each rendered item alistitemwitharia-posinsetandaria-setsize, so the whole length is known though only a part is rendered. While loading, the region isaria-busyand a status line says "Loading moreโฆ"; the placeholder rows are hidden from assistive technology. - Labels
- Name the region with
aria-labeloraria-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.
onLoadMoreis called once for each length ofitems: after a load that adds nothing (a failed request) it is called again only whenitemschanges 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 DownorPage Up | Scroll by a view: the browser's own scrolling. |
| HomeorEnd | Scroll to the first or the last item, through the virtualiser, so measured items land exactly. |
API
VirtualScroller
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | readonly T[] | Every item; only those in view are rendered. | |
renderItemrequired | (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.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--muted-foreground | text |
--ring | outline |