DOM Virtual List Combobox Pattern
Problem
Virtualize a popup listbox (combobox/dropdown style) without hand-wiring Virtualizer attach, count updates, and teardown every time the popup opens or closes.
Solution
Use createDomVirtualList so your component only needs to call setItems(options) to open and setItems([]) to close. The virtualizer is created lazily on the first non-empty setItems() call and destroyed automatically when setItems([]) is called.
ts
import { createDomVirtualList, type DomVirtualListController } from '@vielzeug/scroll';
type Option = { disabled?: boolean; label: string; value: string };
const options: Option[] = Array.from({ length: 2_000 }, (_, i) => ({
label: `Option ${i}`,
value: `opt-${i}`,
}));
let focusedIndex = 0;
let controller: DomVirtualListController<Option> | null = null;
function ensureController() {
if (controller) return controller;
controller = createDomVirtualList<Option>({
estimateSize: 36,
getItemKey: (_, opt) => opt.value,
listElement: document.querySelector<HTMLElement>('[role="listbox"]')!,
overscan: { end: 4, start: 4 },
render: ({ items, listEl, recycle }) => {
for (const item of items) {
const row = recycle(item.data.value, () => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'option';
btn.addEventListener('click', () => console.log('selected', btn.dataset.value));
return btn;
});
row.dataset.value = item.data.value;
row.style.cssText = `position:absolute;top:0;left:0;right:0;transform:translateY(${item.start}px);height:${item.size}px;`;
row.textContent = item.data.label;
row.disabled = !!item.data.disabled;
listEl.appendChild(row);
}
},
scrollElement: document.querySelector<HTMLElement>('.dropdown')!,
});
return controller;
}
function openDropdown() {
focusedIndex = 0;
ensureController().setItems(options); // spawns virtualizer, renders first window
}
function closeDropdown() {
ensureController().setItems([]); // destroys virtualizer, clears list styles
}
function onArrowDown() {
focusedIndex = Math.min(focusedIndex + 1, options.length - 1);
ensureController().scrollToIndex(focusedIndex, { align: 'auto' });
}
function onArrowUp() {
focusedIndex = Math.max(focusedIndex - 1, 0);
ensureController().scrollToIndex(focusedIndex, { align: 'auto' });
}
function destroyCombobox() {
controller?.dispose();
controller = null;
}Pitfalls
- Calling
setItems([])destroys the virtualizer and clears thelistElementheight style. Re-opening callssetItems(options)which spawns a fresh virtualizer. - Holding a stale
controllerreference afterdestroyCombobox()— all method calls are safe no-ops. Re-create the controller on next open instead. - When
getItemKeyis omitted,setItems()drops all cached measurements. PassgetItemKey: (_, opt) => opt.valueto preserve measurements across open/close cycles.