Knit UI
GitHub

Playground

Loading playground…

Playground
<VirtualList
  data={[]}
  renderItem={() => null}
/>

Examples

Basic

10,000 fixed-height rows — scroll freely; only ~a dozen nodes are mounted.

Loading example…

BasicSourceStorybook

Variable Heights

Rows of differing heights — measured on mount and estimated by running average.

Loading example…

Variable HeightsSourceStorybook

With Chrome

Header, footer, and separators between rows.

Loading example…

With ChromeSourceStorybook

Empty

No data → the empty component is shown.

Loading example…

EmptySourceStorybook

Imperative Scroll

Imperative scrolling via the ref handle.

Loading example…

Imperative ScrollSourceStorybook

Infinite Scroll

onEndReached drives infinite loading — rows append as you near the bottom.

Loading example…

Infinite ScrollSourceStorybook

Keep Mounted

keepMounted — rows keep their own state after scrolling out of view. Both lists render the same stateful row (a per-row counter). Bump a few counters near the top, scroll to the bottom, then scroll back: the plain list unmounted those rows and their counts are back to 0, while the keepMounted list kept them in the tree. A number bounds the pool — the least-recently-visible rows are evicted first — so memory stays bounded on a long list.

Loading example…

Keep MountedSourceStorybook

Mount Everything

keepMounted="all" — windowing off entirely. Every row in data is mounted from the first commit, whether or not it has ever been on screen, so find-in-page reaches every row and nothing measures late. This gives up what virtualization buys you — mount cost is O(data) — so it is for lists of a size you would have .map()ed anyway. The counter shows how many rows are really in the tree; compare it with the windowed list beside it.

Loading example…

Mount EverythingSourceStorybook

Styles

Per-slot styles passthrough (Pillar B).

Loading example…

StylesSourceStorybook

Props

PropTypeDefaultDescription
data requiredreadonly T[]—The data to render, one row per element.
renderItem required(info: VirtualListRenderItemInfo<T>) => ReactNode—Render one row. Kept referentially stable (or memoize your rows).
disabled systemboolean | undefined——
drawDistancenumber | undefined250Extra distance (px) rendered beyond each edge of the viewport, so rows exist slightly before they scroll in.
estimatedItemSizenumber | undefined200Seed size (px) for a not-yet-measured row, per axis (here: height). Only an initial guess — measured rows immediately override it and feed the per-type running average. A closer value means less first-scroll correction.
extraDataunknown—Re-render trigger for `renderItem` when external state changes (PureComponent-style).
getItemType((item: T, index: number) => string | number) | undefineda single bucketGroup rows into recycle/estimation buckets. Rows of the same type share a running average size, so heterogeneous lists estimate each kind separately.
handleRefRef<VirtualListHandle> | undefined—Imperative handle; an alternative to the forwarded `ref`.
ItemSeparatorComponentReactNode—Rendered between rows (not after the last). Its height is folded into each row.
keepMountedKeepMounted | undefinedfalseHow much stays mounted beyond the live window. Rows kept this way sit in the tree at their real offsets (clipped by the scroller), so their React state, DOM/native state and any in-flight work survive scrolling away and back — no re-fetch, no reset inputs, no video restarting. An escalating ladder, cheapest first: - `false` (default) — pure virtualization: leaving the window unmounts. - a number — keep at most that many already-seen rows outside the current window, evicting the least-recently-visible first (an LRU warm pool). The safe choice for long lists: bounded memory, and the rows the user is likely to come back to are exactly the ones kept. - `true` — keep every row that has ever been mounted, for as long as it is in `data`. Unbounded, but still LAZY: a row the user has never scrolled to has never mounted, so a 10k-row list costs nothing until it is scrolled. - `"all"` — no windowing at all. Every row in `data` is mounted immediately, whether or not it has ever been on screen, and `drawDistance` is moot. This is the escape hatch for when rows must exist before they are seen — measuring them all up front, letting `Ctrl-F`/find-in-page reach every row, printing, or driving something that needs the whole tree. It gives up the entire point of virtualization: mount cost is O(data), so keep it to lists of a size you would have rendered with `.map()` anyway. Kept rows are still measured and still re-render on `extraData`. Under a number or `true` they do NOT widen {@link VirtualListOwnProps.onRenderedRangeChange}, which keeps reporting the live window; under `"all"` the window IS the whole list, so it reports `{ start: 0, end: data.length - 1 }`.
keyExtractor((item: T, index: number) => string) | undefined—Stable identity per row. Supply this whenever rows can be inserted, removed, reordered or filtered: it is what makes the list's caches follow *items* instead of slots, so a measured height, a retained (`keepMounted`) row and its React state all move with their item. Without it the index is the identity, which is only correct for append/pop — a prepend shifts every measurement onto the wrong row and the list jumps while it re-measures.
ListEmptyComponentReactNode—Rendered instead of rows when `data` is empty.
ListFooterComponentReactNode—Rendered once below the last row (scrolls with content).
ListHeaderComponentReactNode—Rendered once above the first row (scrolls with content).
maintainVisibleContentPositionboolean | undefined—Hold the reading position across a layout change instead of letting the rows slide. Two things stop being jumpy: - A **prepend** (a `onStartReached` page of history) has its inserted height added to the scroll offset in the same frame, so the row the user was looking at does not move. Requires `keyExtractor` — a prepend is detected by finding the previous first row's key at its new index, and index keys carry no identity to find it by. - A non-animated **`scrollToEnd`** holds the bottom while the rows there measure, rather than aiming once at a total that is still mostly estimate. Off by default: it takes over the scroll position, which a list that only grows at the tail has no reason to hand over. Either correction is abandoned the moment the user scrolls — continuing to correct through a live gesture would fight it — and as soon as the rows in question have all been measured, since nothing can move after that.
onEndReached(() => void) | undefined—Fired once as the end (bottom) comes within `onEndReachedThreshold` viewports.
onEndReachedThresholdnumber | undefined0.5Distance from the end at which `onEndReached` fires, in units of visible length (a viewport).
onRenderedRangeChange((range: { start: number; end: number; }) => void) | undefined—The set of currently-rendered row indices whenever it changes. Useful for prefetch/telemetry; not a viewability report (that is phase 2).
onStartReached(() => void) | undefined—Fired once as the start (top) comes within `onStartReachedThreshold` viewports — the mirror of {@link onEndReached}, for a list paginated BACKWARDS (a chat thread loading older messages, a log tailing history). Like `onEndReached` on a list shorter than its viewport, this fires on mount when the list rests at offset 0, because it genuinely IS at the start. A caller that opens the list scrolled elsewhere (`scrollToEnd` on a chat thread) should gate its loader until that initial scroll has been applied, or the first frame spends a page request on nothing. Pair it with `maintainVisibleContentPosition` — without it, the rows the user is reading slide down by the height of whatever gets prepended.
onStartReachedThresholdnumber | undefined0.5Distance from the start at which `onStartReached` fires, in units of visible length (a viewport).
refRef<VirtualListHandle> | undefined——
shadow system"xs" | "sm" | "md" | "lg" | "xl" | "xxs" | "xxl" | undefined——
styles systemSlotStyles<VirtualListStyles> | undefined—Uniform per-slot style passthrough. See {@link VirtualListStyles}.
theme systemThemeName | null | undefined—Applies a theme to this element

Style slots

Every part of VirtualList can be styled through the styles prop. Explicit props on the component always win over slot styles.

SlotTargets
contentProps spread onto the total-size content spacer.
itemProps spread onto each absolutely-positioned row wrapper.
scrollAreaProps spread onto the underlying ScrollArea frame.

Plus 497 inherited style props from Box — the full Tamagui/React Native style surface, including token shorthands like p, mx, bg and c. See Tokens for the scales they accept, or the full list.

Edit this page on GitHub