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.
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.
When to use
| Situation | Use |
|---|---|
| Thousands of rows of different heights in one scrolling column | VirtualList |
| The messages of a conversation that fits in the DOM | MessageList |
| A long column of task cards on a board | VirtualList, or a virtualizer of your own inside the column's ScrollArea (TaskBoard) |
| Any other region that scrolls | ScrollArea |
Anatomy
VirtualList: the scroll container. It is its own native scroller (overflow-y: auto), not a ScrollArea, and hides the native scrollbar unlessshowScrollbar.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, withpaddingTopabove the first row. - The rows, in
data-slot="virtual-list-visible-items": whatchildren(a function) orItemComponentrenders for each item in view. PutdomRefon the row's root element and spreadstyleon it;styleis set only for a row kept mounted outside the view. footer, thenpaddingBottom: optional, below the rows.empty: rendered instead of the spacer when there are no items, at the top or, withemptyAlign="center", in the middle.
componentRef: theVirtualListApi, withscrollTo,getOffsetTopOfKeyand 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
itemResizablefor rows whose height isn't fixed. Rows not yet mounted count asitemEstimatedSize; a row is measured before the browser paints it, and again whenever it resizes. - Set
itemMinSizeto 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-rootorgap.
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 whenitemschange, 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.canAnchorItemexcludes rows that new rows go below, such as a leading header row.
Jumps
scrollTo({ key, align })(orindex) puts the row's top at the view's top ("start"), its middle in the middle ("center") or its bottom at the bottom ("end"), insidescrollMarginTopandscrollMarginBottom. 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
keepAnchorOnItemsChangethey 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.
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.
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. WithitemResizable, 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.columnswithrenderColorrenderItemWithColsscrolls 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
sessionIdresets 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 andkeyboardPageBottomOcclusion; 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
domRefon the row's root element, the element the render function returns: the list finds rows among its own children, so a row withdomRefon a nested element is never measured and the scroll position jumps as it is read. - Keep
getKeystable for an item across renders: a new key is a new row, measured again from the estimate. - Keep the
headerthe 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
| Prop | Type | Default |
|---|---|---|
| With `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 | |
| Scroll 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. | boolean | false |
| Renders 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 | |
| Columns 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[] | |
| The 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> | |
| The index of the row to scroll to when the list mounts or `sessionId` changes. | number | |
| The 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 | |
| A 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> | |
| Rendered in place of the rows when there are no items. A function gets a `domRef`: when its element resizes, `onContentResize` is called. | ReactNode | VirtualListFooterRender | |
| Where `empty` sits: at the top (`"start"`) or in the middle of the list (`"center"`). | "center" | "start" | "start" |
| Put the scroller in the tab order (`tabIndex` 0), so the keyboard can scroll it; off, it is `tabIndex` -1. | boolean | true |
| Rendered below the rows, inside the scroller. A function gets a `domRef`: when its element resizes, `onContentResize` is called. | ReactNode | VirtualListFooterRender | |
| Rendered last in the scroller, after `paddingBottom`. | ReactNode | |
| 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 | |
| Rendered 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 | |
| Stop 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). | boolean | false |
| A component that renders a row, in place of `children`; it gets the same props. | ComponentType<ItemRenderProps<T>> | |
| The size, in px, counted for a row not measured yet (its offset and the spacer's height). | number | |
| 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 | |
| Rows 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. | boolean | false |
| The items, one row each, in order. | readonly T[] | |
| Classes of the spacer (`data-slot="virtual-list-items"`). | string | |
| Styles of the spacer (`data-slot="virtual-list-items"`), merged over its own. | CSSProperties | |
| When `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 | |
| What 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. | number | 0 |
| Called 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 | |
| Called when the list renders too often: 120 forced renders within a second report a `"renderThrashing"` anomaly, for logging. | (anomaly: RenderAnomaly) => void | |
| Called when the scroller itself resizes, with its ResizeObserver entry. | (entry: ObservedResizeEntry) => void | |
| Called when the list scrolls, with the native event, after the rows in view are updated. | (event: Event) => void | |
| Called when the rows' offset (`--virtual-list-transform-y`) changes, for content positioned against it. | () => void | |
| With `catchWheel`: called with the native wheel event, except one let through at either end. | (event: WheelEvent) => void | |
| Space below the footer, in px (before `footerAfterBottomPadding`). | number | 0 |
| Space above the first row, in px, inside the spacer. | number | 0 |
| When printing, let the list grow to its content (no height limit, no scrolling). | boolean | false |
| With `columns`: renders one column of a row; its result is the row's `renderedCols`. | (info: VirtualListRenderColInfo) => ReactNode | |
| With `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 | |
| Identifies 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 | |
| Show the scroller's native scrollbar (hidden by default). | boolean | false |
| Render 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). | boolean | true |
| Props of the element that holds the rows (`visibleItemsTag`), such as `role` or `aria-label`. | object | |
| The 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.
Related
Changes
- 0.12.0 · Fixed
VirtualList: 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 · Added
VirtualList: 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 (scrollTowithalign), 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 asdomRef; styles are in.r-virtual-list. The implementation borrows from DeepSeek web's virtual list.