Skip to content

VirtualList

VirtualList renders only the items near the viewport, so a list of ten thousand rows costs about as much as a screenful. Items can be any height and can change height at any time (an image loads, a message expands): each item is measured as it renders and its height is remembered by key. Whatever you are looking at stays still while items above it load, grow or shrink.

With anchor="end" it is a chat timeline: it starts at the bottom, follows new items while you are at the bottom, keeps your place while older items load above, and can jump to any loaded item.

Demos

Basic

Give the list a height and an estimated item height (itemSize); real heights are measured. scrollToKey lands an item exactly where asked even though most of the heights on the way were estimates.

Chat

anchor="end" rests at the bottom and follows new messages while you are there. Scroll up and it stops following: new messages no longer move the view. Older messages load above (onStartReached) without moving what you are reading, even when they turn out taller than estimated; images here have no known size and grow when they load. Send calls followEnd() so your own message always scrolls into view. Jump to #321 loads a window around a message that is not loaded and jumps to it; the list then knows newer messages exist (hasNewer), so it does not follow, and loads them as you scroll down (onEndReached).

Playground

A test bench for scroll behavior, like vueuc’s virtual list demo: 100,000 rows of mixed heights. Jump by index or key with each alignment, smooth or not; prepend and append; insert after a row (it grows in over 0.3s) or remove one (it collapses); grow a row; change the heights of the rows in view or of every row; hide and show the list; switch between start and end anchors. The numbers below update as you scroll. Whatever is at the top of the view should never move unless you scroll.

How it works

  • Heights are cached by key, so they survive prepending, trimming and items scrolling out and back. Offsets come from a Fenwick tree: changing one height and finding the item at an offset are both O(log n).
  • One owner of the scroll offset at a time. The list keeps a position: an item and its distance from the top of the viewport, the bottom (following), or a jump target. While you read (anchored), the browser’s own scroll anchoring stays on: when something above the view changes size, the browser moves the offset during layout, and the list, checking right after it measures (before paint), sees that the offset already moved and accepts it instead of correcting again. It corrects only what the browser did not (at offset 0 browsers don’t anchor; when the browser doesn’t support it). While following the bottom or jumping, the list switches the browser’s anchoring off on the scroll container and sets the offset itself. Spacers are never anchor candidates. The playground shows both counts.
  • Following changes only when the user scrolls (wheel, touch, keys, the scrollbar). Layout changes, new items and the on-screen keyboard never start or stop it. When the viewport shrinks (keyboard, address bar) a following list stays at the bottom; otherwise it keeps the item in view.
  • Jumps re-aim every frame until the target stays put for two frames (at most a second), so items measured on the way don’t make it land short. A long smooth scroll starts near the target. Any user scroll cancels a jump.
  • Only rendered items are in the page, so the browser’s find-in-page only finds those.

Props

NameTypeDefaultDescriptionVersion
itemsT[](required)The items, in display order.Unreleased
renderItem(item: T, index: number) => ReactNode(required)Renders one item. Its height is measured; keep this function stable (useCallback).Unreleased
keyFieldstringkeyThe item field with a stable, unique key. Heights are remembered by key.Unreleased
itemSizenumber | (item: T, index: number) => number48Estimated height of an item not yet measured, in px.Unreleased
overscannumber400Rendered distance beyond each edge of the viewport, in px.Unreleased
anchorstart | endstartend rests at the bottom and follows new items there.Unreleased
hasNewerbooleanfalseThe last item is not the newest: the list does not follow, and the bottom is not the live end.Unreleased
initialKeystring | numberundefinedItem to show first instead of the top (start) or bottom (end).Unreleased
initialAlignstart | center | end | nearestcenterAlignment of initialKey.Unreleased
endThresholdnumber100Distance from the bottom that still counts as at the end, in px.Unreleased
reachThresholdnumber200Distance from an edge that fires onStartReached / onEndReached, in px.Unreleased
onStartReached() => voidundefinedNear the top: load older items. Fires once per first item, so prepending re-arms it.Unreleased
onEndReached() => voidundefinedNear the bottom: load newer items. Fires once per last item.Unreleased
onAtEndChange(atEnd: boolean) => voidundefinedWhether the list is at the bottom (show a back-to-latest button).Unreleased
onVisibleRangeChange(range: VirtualListVisibleRange) => voidundefinedThe items in view changed (without overscan).Unreleased
deferCorrectionsWhileTouchingbooleanfalseHold height corrections while a finger is down or momentum scrolling runs, and apply them when scrolling stops (iOS cancels momentum when the offset is written).Unreleased
headerReactNodeundefinedContent above the first item, inside the scroll container.Unreleased
footerReactNodeundefinedContent below the last item, inside the scroll container.Unreleased
itemClassNamestringundefinedClass of each item wrapper.Unreleased
sizesmall | mediummediumScrollbar thickness, as on ScrollArea.Unreleased
triggerhover | nonehoverWhen the scrollbar shows, as on ScrollArea.Unreleased

Each item is wrapped in a div with data-key and data-index. The list also takes className, style and aria-label.

VirtualList Methods

NameTypeDescriptionVersion
scrollToKey(key, options?: { align?, smooth? }) => booleanScrolls an item into view (align defaults to start). Returns false when the key is not loaded.Unreleased
scrollToIndex(index, options?) => booleanSame, by index.Unreleased
scrollToStart() => voidGoes to the first item.Unreleased
scrollToEnd() => voidGoes to the last item; an end list follows again.Unreleased
followEnd() => voidFollows the next items even if the user scrolled away, for example right after sending.Unreleased
holdAnchor() => voidStops following and keeps the current view, before a layout change you cause (opening a side panel).Unreleased
isAtEnd() => booleanWithin endThreshold of the bottom.Unreleased
isFollowing() => booleanAn end list that stays at the bottom as items arrive or grow.Unreleased
getScrollElement() => HTMLDivElement | nullThe scroll container.Unreleased
getScrollStats() => { written, accepted }Diagnostics: scroll corrections the list wrote, and browser scroll-anchoring adjustments it accepted instead.Unreleased