Virtualit Usage Guide
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,
getListElement: () => listboxEl,
getScrollElement: () => dropdownEl,
overscan: 4,
render: ({ items, listEl, virtualItems }) => {
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.top}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.update(options, isOpen);
// Keyboard nav helper
domVirtualList.scrollToIndex(focusedIndex, { align: 'auto' });
// Component teardown
domVirtualList.destroy();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.top}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 measureElement(). 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.top}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.measureElement(item.index, el.offsetHeight);
}
});
},
});Measurement is idempotent
measureElement(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: 5, // render 5 extra items above and below the visible window (default: 3)
onChange: () => {
/* ... */
},
});Updating the Count
When your data array grows or shrinks, assign to the count setter. The offset table is rebuilt and onChange fires immediately.
// Load more data
data.push(...newItems);
virt.count = data.length;Switching Row Density
Assigning estimateSize clears all previously measured heights, rebuilds offsets, and re-renders. This makes density switching (compact / comfortable / spacious views) a one-liner.
function setDensity(mode: 'compact' | 'comfortable') {
virt.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());Lifecycle — attach and destroy
createVirtualizer(el, options) attaches immediately. For cases where the scroll container is not available at construction time (e.g. a web component with a lazy shadow root), use the Virtualizer class directly:
import { Virtualizer } from '@vielzeug/virtualit';
// Create without attaching
const virt = new Virtualizer({
count: rows.length,
estimateSize: 36,
onChange: render,
});
// Later, once the element is mounted:
virt.attach(scrollContainerEl);
// To re-attach to a different element (e.g. dropdown re-mount):
virt.attach(newScrollEl);
// Teardown
virt.destroy();destroy() is idempotent — safe to call multiple times or when the element has already been removed from the DOM.
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.
React
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.top}px;left:0;right:0;height:36px;`;
el.textContent = rows[item.index]?.label ?? '';
listEl.appendChild(el);
}
},
});
virtRef.current = virt;
return () => virt.destroy();
}, []); // attach once
// When rows change, update the count
useEffect(() => {
if (virtRef.current) virtRef.current.count = rows.length;
}, [rows.length]);
return (
<div ref={scrollRef} style={{ height: 400, overflow: 'auto', position: 'relative' }}>
<div ref={listRef} style={{ position: 'relative' }} />
</div>
);
}Vue 3
<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.top}px;left:0;right:0;height:36px;`;
el.textContent = props.rows[item.index]?.label ?? '';
listEl.appendChild(el);
}
},
});
});
watch(
() => props.rows.length,
(n) => {
if (virt) virt.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>Svelte 5
<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.top}px;left:0;right:0;height:36px;`;
el.textContent = rows[item.index]?.label ?? '';
listEl.appendChild(el);
}
},
});
return () => virt.destroy();
});
$effect(() => {
if (virt) virt.count = rows.length;
});
</script>
<div bind:this={scrollEl} style="height:400px;overflow:auto;position:relative;">
<div bind:this={listEl} style="position:relative;" />
</div>Lit / Web Components
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.top}px;left:0;right:0;height:36px;`;
el.textContent = this.rows[item.index]?.label ?? '';
listEl.appendChild(el);
}
},
});
}
updated() {
if (this.#virt) this.#virt.count = this.rows.length;
}
disconnectedCallback() {
this.#virt?.destroy();
super.disconnectedCallback();
}
render() {
return html`
<div class="scroll">
<div class="list"></div>
</div>
`;
}
}