Usage Patterns
Variable heights (dynamic measurement)
When item heights differ, pass estimateSize as a best guess and wire measureElement so the virtualizer measures each item after render.
import { Virtualizer } from "@domphy/virtual"
import { createVirtualizer } from "@domphy/virtual/domphy"
import { toState } from "@domphy/core"
const data = toState(Array.from({ length: 10_000 }, (_, i) => ({
id: i,
text: i % 5 === 0 ? "A longer item that wraps to multiple lines." : "Short",
})))
const list = createVirtualizer({
count: 10_000,
estimateSize: () => 40, // initial guess in px — refined on measure
overscan: 5,
})
const App = {
div: {
// scroll container — fixed height
div: [
{
// spacer — holds the total scroll height
div: null,
style: { height: () => `${list.getTotalSize()}px`, position: "relative" },
div: (l) => list.getVirtualItems(l).map((item) => ({
div: data.get()[item.index].text,
_key: item.key,
_onMount: (node) => list.measureElement(node.domElement),
style: {
position: "absolute",
top: 0,
transform: `translateY(${item.start}px)`,
width: "100%",
},
})),
},
],
_onMount: (node) => list.setScrollElement(node.domElement),
_onRemove: () => list.destroy(),
style: { height: "600px", overflow: "auto" },
},
}measureElement replaces the estimate with the actual rendered height and schedules a re-layout.
Window scroll
Virtualize against the browser window instead of a container. Use observeWindowRect and observeWindowOffset options:
import { observeWindowRect, observeWindowOffset, windowScroll } from "@domphy/virtual"
import { createVirtualizer } from "@domphy/virtual/domphy"
const list = createVirtualizer({
count: 5000,
estimateSize: () => 50,
getScrollElement: () => typeof window !== "undefined" ? window : null,
observeElementRect: observeWindowRect,
observeElementOffset: observeWindowOffset,
scrollToFn: windowScroll,
})
const App = {
div: {
// No fixed height on outer container — page scrolls naturally
div: {
div: (l) => list.getVirtualItems(l).map((item) => ({
div: `Row ${item.index}`,
_key: item.key,
style: {
position: "absolute",
top: `${item.start}px`,
height: `${item.size}px`,
},
})),
style: { position: "relative", height: () => `${list.getTotalSize()}px` },
},
_onMount: () => list.setScrollElement(window as any),
_onRemove: () => list.destroy(),
},
}Horizontal virtualization
Swap the scroll axis with horizontal: true. Items are positioned by start along the x-axis:
const list = createVirtualizer({
count: 500,
estimateSize: () => 120, // column width
horizontal: true,
overscan: 3,
})
const App = {
div: {
// flex row inside a horizontally scrollable container
div: {
div: (l) => list.getVirtualItems(l).map((item) => ({
div: `Col ${item.index}`,
_key: item.key,
style: {
position: "absolute",
left: `${item.start}px`,
width: `${item.size}px`,
height: "100%",
},
})),
style: {
position: "relative",
width: () => `${list.getTotalSize()}px`,
height: "100%",
},
},
_onMount: (node) => list.setScrollElement(node.domElement),
_onRemove: () => list.destroy(),
style: { width: "800px", height: "60px", overflowX: "auto", overflowY: "hidden" },
},
}Overscan
overscan (default 1) controls how many items beyond the visible viewport to render. Higher values reduce blank-flash on fast scroll at the cost of rendering more items:
const list = createVirtualizer({
count: 10_000,
estimateSize: () => 40,
overscan: 10, // render 10 extra items before and after the visible range
})For very fast scrollers or touch inertia, overscan: 5–15 is typical.
Sticky items (range extractor)
Implement sticky headers with a custom rangeExtractor. The default extractor returns startIndex..endIndex — extend it to prepend section-header indexes:
import { defaultRangeExtractor } from "@domphy/virtual"
const sectionHeaders = new Set([0, 50, 100, 150]) // item indexes that are headers
const list = createVirtualizer({
count: 200,
estimateSize: () => 40,
rangeExtractor: (range) => {
const active = [...sectionHeaders].filter((i) => i <= range.startIndex)
const last = active[active.length - 1]
const defaults = defaultRangeExtractor(range)
return last !== undefined && !defaults.includes(last)
? [last, ...defaults]
: defaults
},
})Render the sticky header item with position: sticky; top: 0 and a higher z-index than regular rows.
Scroll to index on load
Scroll to an item after mounting (e.g. restore a saved position):
const App = {
div: {
// scroll container...
_onMount: (node) => {
list.setScrollElement(node.domElement)
list.scrollToIndex(savedIndex, { align: "start", behavior: "auto" })
},
},
}align options: "start" | "center" | "end" | "auto" (smallest movement).
Follow on append (infinite scroll)
Use followOnAppend: "anchor" to keep the viewport anchored at the bottom as new items are added — useful for chat messages, live feeds:
const list = createVirtualizer({
count: items.length,
estimateSize: () => 48,
followOnAppend: "anchor", // viewport stays at bottom when count grows
scrollAnchor: "end",
})When the user scrolls up, the anchor is released until they scroll back to the bottom.
Virtualized grid (2D)
For a fixed-column grid, compose two virtualizers — one for rows, one for columns:
const rowList = createVirtualizer({ count: rowCount, estimateSize: () => 50, horizontal: false })
const colList = createVirtualizer({ count: colCount, estimateSize: () => 120, horizontal: true })
const App = {
div: {
div: {
div: (l) => rowList.getVirtualItems(l).map((row) => ({
_key: row.key,
div: (l) => colList.getVirtualItems(l).map((col) => ({
div: `${row.index},${col.index}`,
_key: col.key,
style: {
position: "absolute",
left: `${col.start}px`,
width: `${col.size}px`,
height: `${row.size}px`,
},
})),
style: {
position: "absolute",
top: `${row.start}px`,
left: 0,
height: `${row.size}px`,
width: () => `${colList.getTotalSize()}px`,
},
})),
style: {
position: "relative",
height: () => `${rowList.getTotalSize()}px`,
width: () => `${colList.getTotalSize()}px`,
},
},
_onMount: (node) => {
rowList.setScrollElement(node.domElement)
colList.setScrollElement(node.domElement)
},
_onRemove: () => { rowList.destroy(); colList.destroy() },
style: { height: "600px", width: "100%", overflow: "auto" },
},
}