Data Display@knitui/components
VirtualList
VirtualList is a fast, windowed, variable-height list that renders only the rows near the viewport (plus a render-ahead buffer). One source runs on web and React Native by riding ScrollArea and driving windowing off onScrollPositionChange; row heights are measured via onLayout and fed to a per-type running-average size model, so no exact estimatedItemSize is required. Mirrors the familiar FlatList / FlashList surface (data, renderItem, keyExtractor, getItemType, onEndReached, ListHeaderComponent/ListFooterComponent/ListEmptyComponent/ItemSeparatorComponent) plus an imperative handle (scrollToIndex / scrollToOffset / scrollToEnd). Set keepMounted when rows own state that must survive scrolling out of view — a bounded LRU warm pool (keepMounted={40}) or everything ever mounted (keepMounted).
import { VirtualList } from "@knitui/components";Playground
Loading playground…
<VirtualList
data={[]}
renderItem={() => null}
/>Examples
Basic
10,000 fixed-height rows — scroll freely; only ~a dozen nodes are mounted.
Loading example…
Variable Heights
Rows of differing heights — measured on mount and estimated by running average.
Loading example…
With Chrome
Header, footer, and separators between rows.
Loading example…
Empty
No data → the empty component is shown.
Loading example…
Imperative Scroll
Imperative scrolling via the ref handle.
Loading example…
Infinite Scroll
onEndReached drives infinite loading — rows append as you near the bottom.
Loading example…
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…
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…
Styles
Per-slot styles passthrough (Pillar B).
Loading example…
Props
| Prop | Type | Default | Description |
|---|---|---|---|
data required | readonly 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 system | boolean | undefined | — | |
drawDistance | number | undefined | 250 | Extra distance (px) rendered beyond each edge of the viewport, so rows exist slightly before they scroll in. |
estimatedItemSize | number | undefined | 200 | Seed 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. |
extraData | unknown | — | Re-render trigger for `renderItem` when external state changes (PureComponent-style). |
getItemType | ((item: T, index: number) => string | number) | undefined | a single bucket | Group rows into recycle/estimation buckets. Rows of the same type share a running average size, so heterogeneous lists estimate each kind separately. |
handleRef | Ref<VirtualListHandle> | undefined | — | Imperative handle; an alternative to the forwarded `ref`. |
ItemSeparatorComponent | ReactNode | — | Rendered between rows (not after the last). Its height is folded into each row. |
keepMounted | KeepMounted | undefined | false | How 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. |
ListEmptyComponent | ReactNode | — | Rendered instead of rows when `data` is empty. |
ListFooterComponent | ReactNode | — | Rendered once below the last row (scrolls with content). |
ListHeaderComponent | ReactNode | — | Rendered once above the first row (scrolls with content). |
maintainVisibleContentPosition | boolean | 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. |
onEndReachedThreshold | number | undefined | 0.5 | Distance 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. |
onStartReachedThreshold | number | undefined | 0.5 | Distance from the start at which `onStartReached` fires, in units of visible length (a viewport). |
ref | Ref<VirtualListHandle> | undefined | — | |
shadow system | "xs" | "sm" | "md" | "lg" | "xl" | "xxs" | "xxl" | undefined | — | |
styles system | SlotStyles<VirtualListStyles> | undefined | — | Uniform per-slot style passthrough. See {@link VirtualListStyles}. |
theme system | ThemeName | 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.
| Slot | Usage |
|---|---|
content | styles={{ content: { … } }} |
item | styles={{ item: { … } }} |
scrollArea | styles={{ 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.