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>) => ReactNodeRender 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.
extraDataunknownRe-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> | undefinedImperative handle; an alternative to the forwarded `ref`.
ItemSeparatorComponentReactNodeRendered 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) | undefinedStable 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.
ListEmptyComponentReactNodeRendered instead of rows when `data` is empty.
ListFooterComponentReactNodeRendered once below the last row (scrolls with content).
ListHeaderComponentReactNodeRendered once above the first row (scrolls with content).
maintainVisibleContentPositionboolean | undefinedHold 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) | undefinedFired 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) | undefinedThe set of currently-rendered row indices whenever it changes. Useful for prefetch/telemetry; not a viewability report (that is phase 2).
onStartReached(() => void) | undefinedFired 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> | undefinedUniform per-slot style passthrough. See {@link VirtualListStyles}.
theme systemThemeName | null | undefinedApplies 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.

SlotUsage
contentstyles={{ content: { … } }}
itemstyles={{ item: { … } }}
scrollAreastyles={{ scrollArea: { … } }}

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.