Skip to content

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:

html
<!-- 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:

html
<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.

ts
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.

ts
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.

ts
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.

ts
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.

ts
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.

ts
// 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.

ts
function setDensity(mode: 'compact' | 'comfortable') {
  virt.estimateSize = mode === 'compact' ? 32 : 48;
}

Programmatic Scrolling

scrollToIndex(index, options?)

Scroll to bring a specific item into view.

alignBehaviour
'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
ts
// 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.

ts
// 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.

ts
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:

ts
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

ts
// The `using` keyword calls virt.destroy() automatically at block exit
{
  using virt = createVirtualizer(scrollEl, { count: rows.length, onChange: render });
  // ... use virt ...
} // → virt.destroy() called here

Framework 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

tsx
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

vue
<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

svelte
<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

ts
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>
    `;
  }
}