Skip to content

Virtual scroll

Windowed list — 10k rows, ~25 DOM nodes.

Browsers
Chrome 123+Firefox 121+Safari 17.5+
Tier
2
Bundle compressed
4.16 kB
Forced layout budget
0 ms
Axe budget
0
SSR compatible
Yes
Published contract
Test harness

Playground

Virtual scroll

10,000 team members with checkbox, avatar, and dropdown actions. Only visible rows (+ buffer) mount in the DOM — open DevTools and count [data-uiify-virtual-scroll] [data-part="row"] nodes while scrolling.

User 1
Admin
User 2
Viewer
User 3
Editor
User 4
Admin
User 5
Editor
User 6
Viewer
User 7
Admin
User 8
Viewer
User 9
Editor
User 10
Admin
User 11
Editor
User 12
Viewer
User 13
Admin
User 14
Viewer
User 15
Editor
User 16
Admin
User 17
Editor
User 18
Viewer

Horizontal

The same users as a horizontal strip with orientation="horizontal". rowHeight sets each card's width; the window follows the viewport width.

User 1
Admin
User 2
Viewer
User 3
Editor
User 4
Admin
User 5
Editor
User 6
Viewer
User 7
Admin
User 8
Viewer
User 9
Editor
User 10
Admin
User 11
Editor
User 12
Viewer

API

VirtualScroll

PropTypeDefaultDescription
items*T[]—The full list of items, each with a stable id; only the rows in the current range are rendered.
renderRow*(item: T, index: number) => ReactNode—Renders the contents of one row from its item and absolute index.
heightnumber—Explicit height in pixels. Omit it and pass style={{ blockSize: '100%' }} to fill a definite parent height; 600px is the default when neither is supplied.
rowHeightnumber64Fixed pixel size of each row on the scroll axis (height when vertical, width when horizontal), used to compute scroll size and visible rows.
orientation"vertical" | "horizontal""vertical"Scroll axis. Horizontal lists scroll on the inline axis, size the window from the measured viewport width, mirror correctly in right-to-left layouts, and mirror the value to data-orientation.
overscannumber5Number of extra rows rendered above and below the visible range so fast scrolling does not reveal blank space.
estimateRowHeight(index: number) => number—Per-index height estimate that switches the list to dynamic layout, where rendered rows are measured and the estimate is corrected.
role"list" | "grid""list"ARIA role applied to the scroll container.
anchor"start" | "end""start"Which end of the list the scroll position stays pinned to when items are added or removed.
followAppendboolean | "smooth"falseScrolls to newly appended items when the list is already at the end, optionally with smooth scrolling.
scrollRefRef<VirtualScrollHandle>—Receives the imperative handle exposing scrollToIndex, scrollToOffset and scrollToEnd.
onRangeChange(range: VirtualScrollRangeChange) => void—Called with the visible and overscanned index ranges whenever the rendered window moves.
onScrollStateChange(state: VirtualScrollScrollState) => void—Called when scrolling starts and when it settles.
isScrollingResetDelaynumber150Milliseconds of quiet before scrolling is treated as settled in browsers without the scrollend event.
classNamestring—Class applied to the scroll container.
testIdstring—Value for the scroll container's data-testid attribute.

Keyboard

KeyAction
ScrollWheel, trackpad, or scrollbar moves the window
TabMove focus between row controls (checkbox, menu trigger)
Arrow keysRove items inside an open dropdown menu
EscapeClose an open dropdown menu

Accessibility

A full-height spacer keeps the native scrollbar accurate while only a fixed pool of row slots, plus overscan, is mounted; stable slot keys recycle DOM nodes. Rows expose role="listitem" with aria-posinset and aria-setsize inside a role="list" scrollport. Scroll sync is passive, and large jumps such as the scrollbar, Home/End or Cmd+Arrow update the range synchronously.

Styling hooks

data-uiify-virtual-scrolldata-part="spacer"data-part="row"