New to Virtualit?
Start with the Overview for installation and a quick introduction, then come back here for in-depth patterns.
DOM Layout Requirements
Virtualit uses absolute positioning for rendered items inside a relative container that stretches to the full list height. Your HTML needs three elements:
<!-- 1. Scroll container — has a fixed height and overflow:auto/scroll -->
<div class="scroll-container" style="height:400px;overflow:auto;position:relative;">
<!-- 2. Spacer — height set to totalSize so the scrollbar is correct -->
<div class="spacer" style="position:relative;">
<!-- 3. Item container — items positioned absolutely inside here -->
<div class="items"></div>
</div>
</div>A common alternative is to make the spacer and item container the same element:
<div class="scroll-container" style="height:400px;overflow:auto;">
<!-- Single relative container; items are absolute children -->
<div class="list" style="position:relative;"></div>
</div>DOM Module for Dropdowns and Listboxes
If your component already has a dropdown scroll container and a listbox element, use createDomVirtualList from @vielzeug/virtualit/dom. It wraps the Virtualizer lifecycle and keeps the integration surface tiny.
import { createDomVirtualList } from '@vielzeug/virtualit/dom';
type Option = { disabled?: boolean; label: string; value: string };
let options: Option[] = [];
const domVirtualList = createDomVirtualList<Option>({
clear: (listEl) => {
for (const el of Array.from(listEl.querySelectorAll('.option'))) el.remove();
},
estimateSize: 36,
gap: 6,
getItemKey: (_index, option) => option.value,
getListElement: () => listboxEl,
getScrollElement: () => dropdownEl,
overscan: { start: 4, end: 4 },
render: ({ items, listEl, totalSize, virtualItems }) => {
listEl.style.height = `${totalSize}px`;
for (const item of virtualItems) {
const opt = items[item.index];
if (!opt) continue;
const row = document.createElement('button');
row.type = 'button';
row.className = 'option';
row.style.cssText = `position:absolute;top:0;left:0;right:0;transform:translateY(${item.start}px);`;
row.textContent = opt.label;
row.disabled = !!opt.disabled;
row.addEventListener('click', () => selectOption(opt));
listEl.appendChild(row);
}
},
});
// Keep this in sync when options or open state change
domVirtualList.setItems(options);
domVirtualList.setActive(isOpen);
// Keyboard nav helper
domVirtualList.scrollToIndex(focusedIndex, { align: 'auto' });
// Optional variable-height measurement for rendered rows
domVirtualList.measure(renderedIndex, rowEl.offsetHeight);
// Component teardown
domVirtualList.destroy();For variable-height rows, pass getItemKey whenever the same logical items can be reordered, filtered, or reinserted. Without stable keys, createDomVirtualList clears measured heights on each setItems() call by design.
Use domVirtualList.invalidate() when many row heights changed and you want to discard all cached measurements.
Migration note
If you previously managed createVirtualizer, attach/destroy, and list-height styles manually for a dropdown/listbox, you can move that glue code into createDomVirtualList and keep rendering logic in a single render callback.
Fixed Heights
Pass a single number to estimateSize when all rows are the same height. This is the simplest and most performant case — the offset table never needs to be rebuilt during scrolling.
const virt = createVirtualizer(scrollEl, {
count: 10_000,
estimateSize: 36, // every row is 36px
onChange: (virtualItems, totalSize) => {
list.style.height = `${totalSize}px`;
list.innerHTML = '';
for (const item of virtualItems) {
const el = document.createElement('div');
el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;
el.textContent = data[item.index].name;
list.appendChild(el);
}
},
});Variable Heights — Estimator
Pass a per-index function to estimateSize when rows have predictable but non-uniform heights (e.g. group headers vs. regular rows). The offset table is built once at attach time using these estimates.
const virt = createVirtualizer(scrollEl, {
count: flatList.length,
estimateSize: (i) => (flatList[i].type === 'header' ? 48 : 36),
onChange: (virtualItems, totalSize) => {
// render...
},
});Variable Heights — Measured
For truly dynamic heights (e.g. text wrapping, embedded images), render items at their estimated size first, then report the actual measured height with measure(). Virtualit will coalesce all measurement calls within a single microtask tick into one offset rebuild.
const virt = createVirtualizer(scrollEl, {
count: rows.length,
estimateSize: 60, // initial estimate
onChange: (virtualItems, totalSize) => {
list.style.height = `${totalSize}px`;
list.innerHTML = '';
for (const item of virtualItems) {
const el = document.createElement('div');
el.dataset.index = String(item.index);
el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;`;
el.innerHTML = rows[item.index].html;
list.appendChild(el);
}
// Measure after the DOM has painted
requestAnimationFrame(() => {
for (const item of virtualItems) {
const el = list.querySelector<HTMLElement>(`[data-index="${item.index}"]`);
if (el) virt.measure(item.index, el.offsetHeight);
}
});
},
});Measurement is idempotent
measure(index, height) is a no-op when the new height matches the current effective height (measured or estimated). It is safe to call on every render without triggering unnecessary rebuilds.
Overscan
overscan controls how many extra items render outside the visible viewport on each side. Higher values reduce the chance of blank rows during fast scrolling; lower values keep the DOM smaller.
createVirtualizer(scrollEl, {
count: 1_000,
estimateSize: 36,
overscan: { start: 5, end: 5 }, // render 5 extra items above and below the visible window (default: 3)
onChange: () => {
/* ... */
},
});You can also set asymmetric overscan:
createVirtualizer(scrollEl, {
count: 1_000,
estimateSize: 36,
overscan: { start: 8, end: 2 },
onChange: () => {
/* ... */
},
});Horizontal Lists
Set horizontal: true to virtualize along the X axis.
const virt = createVirtualizer(scrollEl, {
count: chips.length,
estimateSize: 120,
horizontal: true,
onChange: (virtualItems, totalSize) => {
list.style.width = `${totalSize}px`;
for (const item of virtualItems) {
const chip = document.createElement('button');
chip.style.cssText = `position:absolute;left:${item.start}px;top:0;width:${item.size}px;`;
chip.textContent = chips[item.index].label;
list.appendChild(chip);
}
},
});Window Scroll Target
createVirtualizer accepts window as the scroll target.
const virt = createVirtualizer(window, {
count: rows.length,
estimateSize: 40,
initialOffset: 320,
onChange: (virtualItems, totalSize) => {
spacer.style.height = `${totalSize}px`;
renderRows(virtualItems);
},
});Scroll State Hooks
Use onScrollingChange, onScrollEnd, and isScrolling when you need active-scroll UI states.
const virt = createVirtualizer(scrollEl, {
count: rows.length,
estimateSize: 36,
onScrollingChange: (next) => {
scrollEl.dataset.scrolling = String(next);
},
onScrollEnd: (offset) => {
sessionStorage.setItem('lastOffset', String(offset));
},
});
if (virt.isScrolling) {
// e.g. pause expensive row effects
}Updating Options
When your data or render strategy changes, call update() with one or more option fields. Updates are applied atomically and trigger re-render when needed.
// Load more data
data.push(...newItems);
virt.update({ count: data.length });// Change multiple options together
virt.update({ count: data.length, overscan: { start: 5, end: 5 } });
// Rebuild after reordering/filtering stable-key rows
virt.refresh();Switching Row Density
Updating estimateSize clears all previously measured heights, rebuilds offsets, and re-renders. This makes density switching (compact / comfortable / spacious views) straightforward.
function setDensity(mode: 'compact' | 'comfortable') {
virt.update({ estimateSize: mode === 'compact' ? 32 : 48 });
}Programmatic Scrolling
scrollToIndex(index, options?)
Scroll to bring a specific item into view.
align | Behaviour |
|---|---|
'start' | Item top aligns with the container top |
'end' | Item bottom aligns with the container bottom |
'center' | Item is centered in the viewport |
'auto' (default) | No scroll if already fully visible; otherwise scrolls the minimum amount |
// Jump to item 500 at the top of the viewport
virt.scrollToIndex(500, { align: 'start' });
// Smooth-scroll to an item, centering it
virt.scrollToIndex(500, { align: 'center', behavior: 'smooth' });
// Scroll only if the item is not already visible
virt.scrollToIndex(focusedIndex, { align: 'auto' });Out-of-range indices are clamped silently: negative values scroll to item 0, values ≥ count scroll to the last item.
scrollToOffset(offset, options?)
Scroll to an exact pixel position, useful for restoring a previously saved scroll state.
// Restore scroll position
const savedOffset = sessionStorage.getItem('scrollOffset');
if (savedOffset) virt.scrollToOffset(Number(savedOffset));
// Save on scroll
scrollEl.addEventListener('scroll', () => {
sessionStorage.setItem('scrollOffset', String(scrollEl.scrollTop));
});Invalidating Measurements
Call invalidate() after an event that changes item heights without a data change — for example, a font load, a viewport width change that causes text to reflow, or toggling between a grid and list layout.
document.fonts.ready.then(() => virt.invalidate());On variable-height lists, scrollToIndex() uses the current estimate/measured cache. If you need an exact post-layout position after heights change, call invalidate() before scrolling again.
For same-length updates, call setItems() (DOM adapter) or update() (core). If the rendered height of rows changed, call invalidate() before scrolling again.
Lifecycle — create and destroy
createVirtualizer(el, options) attaches immediately to the provided scroll container. If your container is replaced, destroy the old instance and create a new one.
let virt = createVirtualizer(scrollContainerEl, {
count: rows.length,
estimateSize: 36,
onChange: render,
});
function remount(nextScrollContainerEl: HTMLElement) {
virt.destroy();
virt = createVirtualizer(nextScrollContainerEl, {
count: rows.length,
estimateSize: 36,
onChange: render,
});
}destroy() is idempotent and safe to call multiple times.
Explicit Resource Management
// The `using` keyword calls virt.destroy() automatically at block exit
{
using virt = createVirtualizer(scrollEl, { count: rows.length, onChange: render });
// ... use virt ...
} // → virt.destroy() called hereFramework Integration
Virtualit is rendering-layer agnostic. The pattern is always the same: create the virtualizer when your scroll container is mounted, re-render your DOM in onChange, and call destroy() on unmount.
import { createVirtualizer, type Virtualizer } from '@vielzeug/virtualit';
import { useEffect, useRef } from 'react';
interface Row { id: number; label: string; }
function VirtualList({ rows }: { rows: Row[] }) {
const scrollRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const virtRef = useRef<Virtualizer | null>(null);
useEffect(() => {
const scrollEl = scrollRef.current;
const listEl = listRef.current;
if (!scrollEl || !listEl) return;
const virt = createVirtualizer(scrollEl, {
count: rows.length,
estimateSize: 36,
onChange: (virtualItems, totalSize) => {
listEl.style.height = `${totalSize}px`;
listEl.innerHTML = '';
for (const item of virtualItems) {
const el = document.createElement('div');
el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;
el.textContent = rows[item.index]?.label ?? '';
listEl.appendChild(el);
}
},
});
virtRef.current = virt;
return () => virt.destroy();
}, []); // attach once
useEffect(() => { virtRef.current?.update({ count: rows.length }); }, [rows.length]);
return (
<div ref={scrollRef} style={{ height: 400, overflow: 'auto', position: 'relative' }}>
<div ref={listRef} style={{ position: 'relative' }} />
</div>
);
}<script setup lang="ts">
import { createVirtualizer, type Virtualizer } from '@vielzeug/virtualit';
import { onMounted, onUnmounted, ref, watch } from 'vue';
const props = defineProps<{ rows: { id: number; label: string }[] }>();
const scrollRef = ref<HTMLElement | null>(null);
const listRef = ref<HTMLElement | null>(null);
let virt: Virtualizer | null = null;
onMounted(() => {
if (!scrollRef.value || !listRef.value) return;
const listEl = listRef.value;
virt = createVirtualizer(scrollRef.value, {
count: props.rows.length,
estimateSize: 36,
onChange: (virtualItems, totalSize) => {
listEl.style.height = `${totalSize}px`;
listEl.innerHTML = '';
for (const item of virtualItems) {
const el = document.createElement('div');
el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;
el.textContent = props.rows[item.index]?.label ?? '';
listEl.appendChild(el);
}
},
});
});
watch(() => props.rows.length, (n) => { virt?.update({ count: n }); });
onUnmounted(() => virt?.destroy());
</script>
<template>
<div ref="scrollRef" style="height:400px;overflow:auto;position:relative;">
<div ref="listRef" style="position:relative;" />
</div>
</template><script lang="ts">
import { createVirtualizer, type Virtualizer } from '@vielzeug/virtualit';
let { rows }: { rows: { id: number; label: string }[] } = $props();
let scrollEl: HTMLElement;
let listEl: HTMLElement;
let virt: Virtualizer;
$effect(() => {
virt = createVirtualizer(scrollEl, {
count: rows.length,
estimateSize: 36,
onChange: (virtualItems, totalSize) => {
listEl.style.height = `${totalSize}px`;
listEl.innerHTML = '';
for (const item of virtualItems) {
const el = document.createElement('div');
el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;
el.textContent = rows[item.index]?.label ?? '';
listEl.appendChild(el);
}
},
});
return () => virt.destroy();
});
$effect(() => { virt?.update({ count: rows.length }); });
</script>
<div bind:this={scrollEl} style="height:400px;overflow:auto;position:relative;">
<div bind:this={listEl} style="position:relative;" />
</div>import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { createVirtualizer, type Virtualizer } from '@vielzeug/virtualit';
@customElement('virtual-list')
class VirtualList extends LitElement {
static styles = css`.scroll{height:400px;overflow:auto;position:relative;}.list{position:relative;}`;
@property({ type: Array }) rows: { label: string }[] = [];
#virt: Virtualizer | null = null;
firstUpdated() {
const scrollEl = this.renderRoot.querySelector<HTMLElement>('.scroll')!;
const listEl = this.renderRoot.querySelector<HTMLElement>('.list')!;
this.#virt = createVirtualizer(scrollEl, {
count: this.rows.length,
estimateSize: 36,
onChange: (virtualItems, totalSize) => {
listEl.style.height = `${totalSize}px`;
listEl.innerHTML = '';
for (const item of virtualItems) {
const el = document.createElement('div');
el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;
el.textContent = this.rows[item.index]?.label ?? '';
listEl.appendChild(el);
}
},
});
}
updated() { this.#virt?.update({ count: this.rows.length }); }
disconnectedCallback() { this.#virt?.destroy(); super.disconnectedCallback(); }
render() { return html`<div class="scroll"><div class="list"></div></div>`; }
}Pitfalls
- React: Putting
rowsin theuseEffectdependency array causes the virtualizer to be destroyed and recreated on every data update. Only include the scroll element reference. Callvirt.update({ count })from a separateuseEffectfor data changes. - Vue 3:
ref.valueisnullinsidesetup()— the DOM doesn't exist yet. Always create the virtualizer insideonMounted, not insetup(). - Svelte: In Svelte 5,
$effectwithbind:thisruns after the DOM is painted. Thebind:thisvariable is available when the$effectruns — no extra tick needed. - Web Components:
firstUpdatedfires once after the first render. Useupdated()for subsequent prop changes — Lit calls it every timerowschanges.
Working with Other Vielzeug Libraries
With Craftit
Build a virtualizing custom element using Craftit for the component shell and Virtualit for the rendering engine.
import { define, html, onMounted, ref } from '@vielzeug/craftit';
import { createVirtualizer } from '@vielzeug/virtualit';
define('virtual-list', {
setup() {
const scrollRef = ref<HTMLElement>();
const listRef = ref<HTMLElement>();
onMounted(() => {
if (!scrollRef.value || !listRef.value) return;
const listEl = listRef.value;
const virt = createVirtualizer(scrollRef.value, {
count: 1000,
estimateSize: 40,
onChange: (items, totalSize) => {
listEl.style.height = `${totalSize}px`;
listEl.innerHTML = items.map((i) => `<div style="position:absolute;top:${i.start}px;height:40px;">Row ${i.index}</div>`).join('');
},
});
return () => virt.destroy();
});
return () => html`
<div ref=${scrollRef} style="height:400px;overflow:auto;position:relative">
<div ref=${listRef} style="position:relative"></div>
</div>
`;
},
});Best Practices
- Always provide
countandestimateSizeas a starting point, even for variable-height lists — measurements refine the estimates. - Call
destroy()in the framework cleanup callback (useEffect return, onUnmounted, onDestroy) to free resize observers. - Use
overscanto pre-render rows above and below the visible area to reduce blank flicker during fast scrolling. - Prefer
scrollToIndex()withalign: 'start'for programmatic navigation; usealign: 'center'for focus management. - Use
createDomVirtualList()for comboboxes, listboxes, and selects — it manages the container DOM for you. - Invalidate measurements with
invalidate()when item content changes size (e.g., after expanding an accordion row). - For very large lists (>100k items), set a narrower
overscanto limit DOM node count at any one time.