VirtualList
A long list, or a grid of rows and columns, that renders only the items near the viewport, with measured row heights.
Ten thousand rows
Only the rows near the viewport are in the DOM. range is two-way: the list writes it, the caller loops over it.
Code
<VirtualList count={rows.length} range={state.range} estimateSize={40} label="People" class="list">
<VirtualItem e:for={i of state.range} e:key={(i) => rows[i].id} index={i}>
<div class="row">{rows[i].name}</div>
</VirtualItem>
</VirtualList>Usage
import { VirtualList, VirtualItem, VirtualCell, initialRange } from "@elements/ui/virtual-list";
<!-- private state = { range: initialRange(people.length, 20) } -->
<VirtualList count={people.length} range={state.range} estimateSize={48} label="People" class="people-list">
<VirtualItem e:for={i of state.range} e:key={(i) => people[i].id} index={i}>
{people[i].name}
</VirtualItem>
</VirtualList>
<!-- the page scrolls instead of the list -->
<VirtualList scroller="window" count={rows.length} range={state.range}>...</VirtualList>
<!-- a grid: rows and columns both window in one scroller -->
<VirtualList role="grid" count={10000} columns={1000} range={state.rows} columnRange={state.cols} estimateSize={36} estimateColumnSize={120}>
<VirtualItem e:for={r of state.rows} e:key={(r) => r} index={r}>
<VirtualCell e:for={c of state.cols} e:key={(c) => c} column={c}>{r}, {c}</VirtualCell>
</VirtualItem>
</VirtualList>Examples
Rows of any height
Each row is measured as it renders; estimateSize only sizes the scrollbar until then.
Code
<!-- rows of any height: each is measured as it renders -->
<VirtualList count={notes.length} range={state.range} estimateSize={72}>
<VirtualItem e:for={i of state.range} e:key={(i) => notes[i].id} index={i}>
<p>{notes[i].note}</p>
</VirtualItem>
</VirtualList>Fixed size
measure=false: every row is exactly estimateSize and nothing is measured, the fastest path for uniform rows. paddingStart and paddingEnd add space around the items.
Code
<!-- measure=false: every row is exactly estimateSize, nothing is measured -->
<VirtualList count={rows.length} range={state.range} estimateSize={36} measure={false} paddingStart={8} paddingEnd={8}>...</VirtualList>Horizontal
orientation=horizontal scrolls sideways; sizes per index come from a function. With dir=rtl the list starts at the right.
Code
<VirtualList orientation="horizontal" count={10000} range={state.range} estimateSize={(i) => i % 3 ? 96 : 140}>
<VirtualItem e:for={i of state.range} e:key={(i) => i} index={i}>Column {i}</VirtualItem>
</VirtualList>Masonry lanes
lanes=3 with gap: each tile goes in the shortest lane.
Code
<!-- masonry: 3 lanes, each item goes in the shortest -->
<VirtualList count={tiles} lanes={3} gap={8} range={state.range} estimateSize={(i) => heights[i % 6]}>
<VirtualItem e:for={i of state.range} e:key={(i) => i} index={i}>...</VirtualItem>
</VirtualList>Rows and columns
A grid of ten thousand rows by a thousand columns: columns adds a second virtualizer on the same scroller, columnRange is two-way, and each row loops over it with VirtualCell. role=grid makes rows and gridcells with their row and column index.
Code
<!-- rows and columns both window, in one scroller -->
<VirtualList role="grid" label="Sheet" count={10000} columns={1000}
range={state.rows} columnRange={state.cols}
estimateSize={36} estimateColumnSize={(c) => widths[c]}>
<VirtualItem e:for={r of state.rows} e:key={(r) => r} index={r}>
<VirtualCell e:for={c of state.cols} e:key={(c) => c} column={c}>{r}, {c}</VirtualCell>
</VirtualItem>
</VirtualList>Sticky headers
sticky names the header indexes; the current one stays pinned at the top.
Code
<VirtualList count={rows.length} sticky={headerIndexes} range={state.range} estimateSize={32}>
<VirtualItem e:for={i of state.range} e:key={(i) => rows[i].id} index={i}>
<div class={rows[i].header ? "header" : "contact"}>{rows[i].text}</div>
</VirtualItem>
</VirtualList>Load more at the end
onEndReached fires once per count when the last item renders. Pairs with a windowed LiveView's more().
Code
<VirtualList count={state.loaded.length + 1} range={state.range} onEndReached={() => loadMore(state)}
role="feed" busy={state.loading}>
<VirtualItem e:for={i of state.range} e:key={(i) => state.loaded[i]?.id ?? "loading"} index={i}>
<div e:if={i < state.loaded.length}>{state.loaded[i].name}</div>
<div e:else>Loading more...</div>
</VirtualItem>
</VirtualList>Chat: anchored to the end
anchor=end keeps the bottom still as items change; followOnAppend keeps new messages in view.
Code
<!-- anchor end: new messages stay in view when you are at the bottom -->
<VirtualList count={state.messages.length} anchor="end" followOnAppend initialOffset={999999} range={state.range}>...</VirtualList>Chat: smooth follow
followOnAppend="smooth": a new message at the end glides into view instead of jumping
Code
<VirtualList count={state.messages.length} anchor="end" followOnAppend="smooth" initialOffset={999999} range={state.range}>...</VirtualList>Scroll to an item
onVirtualizer hands over the virtualizer: scrollToIndex with align and smooth behavior. scrollPaddingStart keeps 8px above an item scrolled to the start.
Code
<VirtualList count={rows.length} range={state.range} onVirtualizer={(v) => list = v}>...</VirtualList>
<button type="button" onclick={() => list.scrollToIndex(5000, { align: "start" })}>Go to 5000</button>The page scrolls
scroller=window: no inner scrollbar; the list is as tall as its items and renders the part of it on screen.
Code
<!-- the page scrolls; the list is as tall as its items -->
<VirtualList scroller="window" count={rows.length} range={state.range} estimateSize={40}>...</VirtualList>Range extractor
rangeExtractor picks the indexes to render: here the default window plus row 1, which stays in the DOM however far the list scrolls (for a pinned row, or focus kept on it).
Code
<VirtualList count={rows.length} range={state.range}
rangeExtractor={(r) => [...new Set([0, ...defaultRangeExtractor(r)])]}>...</VirtualList>Restored sizes
virtualizerOptions passes any other virtualizer option through. initialMeasurementsCache restores sizes measured in an earlier visit (takeSnapshot), so the scrollbar is right before a row renders.
Code
let sizes = list.takeSnapshot(); // earlier
<VirtualList count={200} range={state.range} estimateSize={20}
virtualizerOptions={{ initialMeasurementsCache: sizes }}>...</VirtualList>Cached sizes, scrollend
useCachedMeasurements: the list keeps the sizes it was given (64px here) and skips measuring rows. useScrollendEvent: scrolling ends on the browser's scrollend event, not after isScrollingResetDelay (5 seconds here).
Code
<VirtualList count={200} range={state.range}
virtualizerOptions={{ initialMeasurementsCache: sizes, useCachedMeasurements: true, useScrollendEvent: true, isScrollingResetDelay: 5000 }}>...</VirtualList>