Raft Design System

Components · Data Display

VirtualList

A scroll container for long lists whose rows have different heights, known only once they mount; it renders the rows in view and keeps what the person is reading still while sizes and rows change.

StableAlso called Virtualized list, Windowed list, Infinite scroll
import { VirtualList } from "@botiverse/raft-ui";

VirtualList renders only the rows in or near the view of a long list, measures each row when it mounts and whenever it resizes, and keeps the rows in view still while sizes change, rows load above them or the list jumps. Use it for a history or a feed of thousands of rows of different heights: a channel, a log, an activity feed.

virtual-list/compositions/basic

When to use

SituationUse
Thousands of rows of different heights in one scrolling columnVirtualList
The messages of a conversation that fits in the DOMMessageList
A long column of task cards on a boardVirtualList, or a virtualizer of your own inside the column's ScrollArea (TaskBoard)
Any other region that scrollsScrollArea

Anatomy

  • VirtualList: the scroll container. It is its own native scroller (overflow-y: auto), not a ScrollArea, and hides the native scrollbar unless showScrollbar. data-slot="virtual-list".
    • header: optional, above the rows, inside the scroller.
    • The spacer, data-slot="virtual-list-items": as tall as every row together, with paddingTop above the first row.
    • The rows, in data-slot="virtual-list-visible-items": what children (a function) or ItemComponent renders for each item in view. Put domRef on the row's root element and spread style on it; style is set only for a row kept mounted outside the view.
    • footer, then paddingBottom: optional, below the rows.
    • empty: rendered instead of the spacer when there are no items, at the top or, with emptyAlign="center", in the middle.
  • componentRef: the VirtualListApi, with scrollTo, getOffsetTopOfKey and the scroller's element.

getKey gives each item a stable key; every measurement is kept by key, so it survives reordering and new items. Each measured row gets data-virtual-list-item-key. The list writes --virtual-list-transform-y (the rows' offset) on the rows' element and --virtual-list-width on the scroller, for sticky content inside a row to read.

Its styles are plain CSS, styles/virtual-list.css, which styles.css includes: the scroller is .r-virtual-list and the empty node .r-virtual-list__empty. They sit in the components layer, so a utility in className overrides them.

Behavior

Heights

  • Set itemResizable for rows whose height isn't fixed. Rows not yet mounted count as itemEstimatedSize; a row is measured before the browser paints it, and again whenever it resizes.
  • Set itemMinSize to the shortest row's height or less: the list mounts as many rows as fit at that size, so a larger value leaves the end of the view empty.
  • Rows are laid out in normal block flow, so their margins collapse as the CSS says: between neighboring rows, and a child's margin through a row with no border or padding of its own. The list measures the space each row takes, so rows need no display: flow-root or gap.
virtual-list/compositions/margins

Rows that change above the view

  • Rows that mount above the view while the person scrolls up, and rows above the view that resize (an image loads, a reaction is added), have their size change scrolled away: the rows in view don't move. A row the view's top cuts through isn't compensated, because part of it is in view.
  • With keepAnchorOnItemsChange, the row at the top of the view keeps its place when items change, so older rows loaded above it don't push the rows being read down. Load the next page when the person comes near the top. canAnchorItem excludes rows that new rows go below, such as a leading header row.
virtual-list/compositions/load-older

Jumps

  • scrollTo({ key, align }) (or index) puts the row's top at the view's top ("start"), its middle in the middle ("center") or its bottom at the bottom ("end"), inside scrollMarginTop and scrollMarginBottom. The row lands exactly, and no other position is painted first, even when the row and its neighbors were never measured.
  • A newer jump stops an older one. At the ends the position is clamped. With behavior: "smooth" the list scrolls once, to the position known when the jump starts.
  • A jump holds its row by key. Rows loaded above it while it is still settling don't make it land on another row; with keepAnchorOnItemsChange they don't move its row either.
  • Without align, the list scrolls only as far as needed to show the row (debounce, the default), from its estimated offset.
  • scrollTo({ position: "top" | "bottom" }) goes to either end.
virtual-list/compositions/jump

Following the end

The list doesn't follow new rows on its own: whether to follow is the product's choice. To keep a chat at the newest message, track whether the person is at the bottom in onScroll, and while they are, call scrollTo({ position: "bottom" }) when rows are added and from onContentResize, which runs when a row grows, such as a reply that streams in.

virtual-list/compositions/chat

Other behavior

  • While text is selected, rows that scroll out of view stay mounted, so the selection and copying survive scrolling.
  • The row that holds focus stays mounted, at its place, when it scrolls out of view: by End or Page Down, which the browser handles by scrolling the list, by the wheel or by the scrollbar. Unmounting it would drop focus to the page, and the next Tab would start from the top. Once focus leaves the row, it unmounts when it is out of view. Switching to another window or tab doesn't count: the row keeps focus, and is still there when the person comes back. This needs itemResizable, which marks each row with its key.
  • virtual={false} renders every row in flow. With itemResizable, the row at the top of the view keeps its place when virtualization is switched off; switching it back on keeps the measurements, not the position.
  • columns with renderCol or renderItemWithCols scrolls sideways too and renders only the columns in view.
  • On iOS, size changes above the view during a fling are applied as a transform and written to the scroll position after it, because writing it stops the fling.
  • A new sessionId resets the list: scroll position, measurements and rows.

Accessibility

  • VirtualList renders plain elements and adds no role. Give the list the semantics of what it holds, such as a named region around it. Only the rows in or near the view are in the DOM, so a screen reader can't count the others: say how many there are in the region's name or nearby when it matters.
  • The scroller is in the tab order (focusable, on by default). Page Up, Page Down and Space (Shift+Space up) scroll by the view's height less 32px and keyboardPageBottomOcclusion; Home, End, Cmd+Up and Cmd+Down go to either end. Only keys pressed on the scroller itself scroll it: keys pressed on a control inside a row, such as Space on a button or End in a segmented control, are left to that control.
  • Find in page only finds rows in the DOM. Give a long history its own search.

Rules

  • Put domRef on the row's root element, the element the render function returns: the list finds rows among its own children, so a row with domRef on a nested element is never measured and the scroll position jumps as it is read.
  • Keep getKey stable for an item across renders: a new key is a new row, measured again from the estimate.
  • Keep the header the same height while rows load above: a change in its height moves the rows in view.

Provenance

The implementation borrows from DeepSeek web's virtual list implementation. Comments in the source that start "Unlike the reference implementation (DeepSeek web)" mark where it behaves differently: rows in block flow with collapsing margins, measured from layout, and the first row's top margin kept apart.

API

VirtualList

PropTypeDefault
canAnchorItemWith `keepAnchorOnItemsChange`: which rows may be the row kept in place. A row this rejects is passed over like a removed one (the first accepted row after it keeps its place). For rows that new items are inserted below, such as a leading header row above a prepend: keeping such a row in place would push the rows below it, the ones being read. All rows may by default.(item: T) => boolean
catchWheelScroll by the wheel in the list's own handler (where the pointer is not coarse), so rows are rendered in the same frame as the scroll; it lets the wheel through at either end.booleanfalse
childrenRenders a row: called for each item in or near the view with the item, its index, the `domRef` to put on the row's root element and the `style` to spread on it. Give the element the `key` it gets.(props: ItemRenderProps<T> & { key: string | undefined; }) => ReactNode
columnsColumns for a list that also scrolls sideways: only the columns in view are rendered, through `renderCol` or `renderItemWithCols`. Each column's `width` is read.readonly Column[]
componentRefThe imperative API: `scrollTo`, `getOffsetTopOfKey`, the scroller's and spacer's elements.((instance: VirtualListApi | null) => void | (() => void | { [UNDEFINED_VOID_ONLY]: never; }) | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES[keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES]) | RefObject<VirtualListApi | null>
defaultScrollIndexThe index of the row to scroll to when the list mounts or `sessionId` changes.number
defaultScrollKeyThe key of the row to scroll to when the list mounts or `sessionId` changes (as far as needed to show it). `defaultScrollIndex` wins when both are set.string
domRefA ref to the scroller's element.((instance: HTMLDivElement | null) => void | (() => void | { [UNDEFINED_VOID_ONLY]: never; }) | DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES[keyof DO_NOT_USE_OR_YOU_WILL_BE_FIRED_CALLBACK_REF_RETURN_VALUES]) | RefObject<HTMLDivElement | null>
emptyRendered in place of the rows when there are no items. A function gets a `domRef`: when its element resizes, `onContentResize` is called.ReactNode | VirtualListFooterRender
emptyAlignWhere `empty` sits: at the top (`"start"`) or in the middle of the list (`"center"`)."center" | "start""start"
focusablePut the scroller in the tab order (`tabIndex` 0), so the keyboard can scroll it; off, it is `tabIndex` -1.booleantrue
footerRendered below the rows, inside the scroller. A function gets a `domRef`: when its element resizes, `onContentResize` is called.ReactNode | VirtualListFooterRender
footerAfterBottomPaddingRendered last in the scroller, after `paddingBottom`.ReactNode
getKey (required)A key for each item, stable across renders: measurements are kept by key, so they survive reordering and new items, and a new key is a new row, measured again from the estimate.(item: T) => string
headerRendered above the rows, inside the scroller. Keep its height the same while rows load above: a change in its height moves the rows in view.ReactNode
ignoreItemResizeStop applying row measurements: sizes measured while it is on are dropped, and the rows keep the sizes they had (for a moment when rows resize but the layout should not follow, such as an animation).booleanfalse
ItemComponentA component that renders a row, in place of `children`; it gets the same props.ComponentType<ItemRenderProps<T>>
itemEstimatedSize (required)The size, in px, counted for a row not measured yet (its offset and the spacer's height).number
itemMinSize (required)The shortest a row can be, in px, or less: the list mounts as many rows as fit in the view at this size, plus one, so a larger value leaves the end of the view empty.number
itemResizableRows have heights of their own: each row is measured from layout when it mounts and whenever it resizes (put the row's `domRef` on its root element). Off, every row is `itemEstimatedSize` high.booleanfalse
itemsThe items, one row each, in order.readonly T[]
itemsClassNameClasses of the spacer (`data-slot="virtual-list-items"`).string
itemsStyleStyles of the spacer (`data-slot="virtual-list-items"`), merged over its own.CSSProperties
keepAnchorOnItemsChangeWhen `items` change, keep the row at the top of the view where it is: the same row stays at the same distance from the view's top, so rows inserted or removed above it (most often a prepend, such as loading older history) do not move what is on screen. Also at the very top (`scrollTop` 0): the row that was first stays first in view and the new rows are above it, out of view. If that row was removed, the first row after it that is still there keeps its place; if none is left, `scrollTop` is kept. An instant jump still pending (`scrollTo` an index, a key, the top or the bottom, in the same frame) wins. Only with `virtual` on; a new `sessionId` resets the list as before.boolean
keyboardPageBottomOcclusionWhat covers the bottom of the list, in px (such as a composer over it): Page Up, Page Down and Space scroll by the view's height less this and 32px.number0
onContentResizeCalled when the content's height changes: a row resizes or mounts measured, the spacer's height changes, or the `footer` / `empty` element resizes (to follow the end of a chat).() => void
onRenderAnomalyCalled when the list renders too often: 120 forced renders within a second report a `"renderThrashing"` anomaly, for logging.(anomaly: RenderAnomaly) => void
onResizeCalled when the scroller itself resizes, with its ResizeObserver entry.(entry: ObservedResizeEntry) => void
onScrollCalled when the list scrolls, with the native event, after the rows in view are updated.(event: Event) => void
onTransformChangeCalled when the rows' offset (`--virtual-list-transform-y`) changes, for content positioned against it.() => void
onWheelWith `catchWheel`: called with the native wheel event, except one let through at either end.(event: WheelEvent) => void
paddingBottomSpace below the footer, in px (before `footerAfterBottomPadding`).number0
paddingTopSpace above the first row, in px, inside the spacer.number0
printableWhen printing, let the list grow to its content (no height limit, no scrolling).booleanfalse
renderColWith `columns`: renders one column of a row; its result is the row's `renderedCols`.(info: VirtualListRenderColInfo) => ReactNode
renderItemWithColsWith `columns`: renders the columns in view of a row at once (in place of `renderCol`); its result is the row's `renderedItemWithCols`.(info: VirtualListRenderItemWithColsInfo) => ReactNode
sessionIdIdentifies what the list shows (a channel, a thread). A new value resets the list: scroll position, measurements and rows (every row remounts), then `defaultScrollKey` / `defaultScrollIndex` apply again.string
showScrollbarShow the scroller's native scrollbar (hidden by default).booleanfalse
virtualRender only the rows in or near the view. Off, every row is rendered in flow (with `itemResizable`, the row at the top of the view keeps its place when it is switched off).booleantrue
visibleItemsPropsProps of the element that holds the rows (`visibleItemsTag`), such as `role` or `aria-label`.object
visibleItemsTagThe element that holds the rows (`data-slot="virtual-list-visible-items"`), such as `"ul"` for rows that are `li`.ElementType"div"

Also takes the attributes of its element.

Changes

  • 0.12.0 · FixedVirtualList: a jump holds its target row by key, so rows prepended while it settles don't move it; the focused row stays mounted across a window switch.
  • 0.11.0 · AddedVirtualList: a virtualized list for long, variable-height content such as a conversation, exported from the main entry. Rows are measured in block flow, so their margins collapse as in a plain list; it holds its anchor through items added above or below, jumps exactly to an item (scrollTo with align), lays out multi-column rows, and keeps mounted the row that holds focus (so End, Home, paging and wheel scrolling don't lose it) and the rows a text selection reaches into. The scroller takes native HTML attributes and its ref as domRef; styles are in .r-virtual-list. The implementation borrows from DeepSeek web's virtual list.