/ Docs

Getting Started #

Install vlist, configure your first list, and understand the core API.

Installation #

npm install vlist
# or: bun add vlist  |  pnpm add vlist  |  yarn add vlist

Basic Usage #

A container element with a defined height is required — virtual scrolling needs a fixed viewport to calculate which items are visible.

<div id="list" style="height: 500px;"></div>
import { vlist } from 'vlist';
import 'vlist/styles';

const list = vlist({
  container: '#list',
  items: [
    { id: 1, name: 'Alice' },
    { id: 2, name: 'Bob' },
    { id: 3, name: 'Charlie' },
  ],
  item: {
    height: 48,
    template: (item) => `<div class="row">${item.name}</div>`,
  },
}).build();

That's a working virtual list. Thousands of items, only ~20 DOM nodes.


Item Configuration #

Fixed height #

item: {
  height: 48,
  template: (item) => `<div>${item.name}</div>`,
}

Fixed height is the fast path — O(1) scroll math, no caching needed.

Variable height #

item: {
  height: (index) => items[index].expanded ? 120 : 48,
  template: (item) => `<div>${item.name}</div>`,
}

The function receives the index (not the item) so you can look up any data. Heights are cached via a prefix-sum array for O(1) offset lookups and O(log n) binary search for reverse lookups.

Item gap #

item: {
  height: 48,
  gap: 8,       // 8px between each item
  template: (item) => `<div>${item.name}</div>`,
}

The gap property adds consistent spacing between items along the main axis. It's baked into the size cache (slot = itemSize + gap) and subtracted from the DOM element height — no CSS margin hacks needed. The trailing gap after the last item is automatically removed. Works with fixed sizes, variable sizes, and auto-measurement (Mode B). Ignored when withGrid or withMasonry is active (they manage their own gap).

Template function #

type ItemTemplate<T> = (
  item: T,
  index: number,
  state: { selected: boolean; focused: boolean }
) => string | HTMLElement;

The third argument state carries selection and focus state. Return either an HTML string or a DOM element.

item: {
  height: 56,
  template: (user, index, { selected }) => `
    <div class="user-row ${selected ? 'user-row--selected' : ''}">
      <img src="${user.avatar}" />
      <span>${user.name}</span>
    </div>
  `,
}

Items must have an id field (string | number). It's used internally for identity during updates and selection.


Core Config Options #

interface BuilderConfig<T> {
  container: HTMLElement | string;  // Required: selector or element
  item: {
    height?: number | ((index: number) => number);  // Size for vertical (or use estimatedHeight)
    width?: number | ((index: number) => number);   // Size for horizontal
    estimatedHeight?: number;           // Auto-measure mode (requires withAutoSize)
    estimatedWidth?: number;            // Auto-measure mode for horizontal
    gap?: number;                       // Spacing between items (default: 0)
    striped?: boolean | 'data' | 'even' | 'odd'; // Zebra-stripe styling (default: false)
    template: (item: T, index: number, state: ItemState) => string | HTMLElement;
  };
  items?: T[];                          // Static items (omit when using withAsync)
  overscan?: number;                    // Extra items rendered outside viewport (default: 3)
  orientation?: 'vertical' | 'horizontal'; // Default: 'vertical'
  padding?: number | [number, number] | [number, number, number, number]; // Content inset (default: 0)
  reverse?: boolean;                    // Bottom-anchored content (default: false)
  interactive?: boolean;                // Enable keyboard navigation (default: true)
  classPrefix?: string;                 // CSS class prefix (default: 'vlist')
  ariaLabel?: string;                   // Accessible label for the list element
}

Content Padding #

The top-level padding property adds inset space between the viewport edge and the items, exactly like CSS padding. It follows the CSS shorthand convention:

padding: 16                   // 16px all sides
padding: [16, 12]             // 16px top/bottom, 12px left/right
padding: [16, 12, 20, 8]     // top, right, bottom, left

Applied as CSS padding + border-box on .vlist-content, so it works identically for list, grid, and masonry layouts with zero positioning overhead. Grid and masonry automatically subtract cross-axis padding from the container width so columns/lanes size correctly. scrollToIndex accounts for padding so the last item scrolls fully into view.

const list = vlist({
  container: '#list',
  padding: [24, 16],       // 24px top/bottom, 16px left/right
  items: data,
  item: {
    height: 48,
    gap: 8,                // combine with gap for full spacing control
    template: (item) => `<div>${item.name}</div>`,
  },
}).build()

Scroll Configuration #

The scroll key controls the scroll system behaviour. All fields are optional.

scroll?: {
  element?: Window;                  // Override scroll container (use window for page-level scroll)
  wheel?: boolean;                   // Enable mouse wheel (default: true)
  wrap?: boolean;                    // Circular navigation (default: false)
  gutter?: 'auto' | 'stable';       // Native scrollbar space reservation (default: 'auto')
  idleTimeout?: number;              // ms of no-scroll before 'idle' event fires (default: 150)
}

Scrollbar gutter #

On macOS with "When scrolling" or "Automatically" overlay scrollbars, the native scrollbar takes zero width. On Windows, Linux, and macOS with "Always" scrollbars, the classic scrollbar takes ~15-17px from the content area and can cause layout shift when it appears or disappears.

gutter: 'stable' applies `scrollbar-gutter: stable` to the viewport, permanently reserving space for the scrollbar:

const list = vlist({
  container: '#list',
  items: contacts,
  item: { height: 64, template: renderContact },
  scroll: { gutter: 'stable' },
}).build();
Value Behavior
'auto' (default) Native browser behavior — overlay on macOS, classic on Windows/Linux
'stable' Always reserves scrollbar space — no layout shift when content grows

Note: Has no effect when withScrollbar() is active — the custom scrollbar is position: absolute and doesn't affect layout.

Window / page scrolling #

Pass window as the scroll element to let the whole page scroll instead of the container. This is also what withPage() does — use the feature when you want to avoid configuring it manually.

const list = vlist({
  container: '#list',
  items: articles,
  item: { height: 300, template: renderArticle },
  scroll: { element: window },
}).build();

Disabling the mouse wheel #

Useful for wizard-style interfaces where navigation is button-driven:

const wizard = vlist({
  container: '#steps',
  items: steps,
  item: { height: 600, template: renderStep },
  scroll: { wheel: false },
}).build();

// Drive navigation programmatically
document.querySelector('#next').addEventListener('click', () => {
  wizard.scrollToIndex(currentStep + 1, { align: 'start', behavior: 'smooth' });
});

Circular navigation #

wrap: true makes scrollToIndex wrap around — handy for carousels:

const carousel = vlist({
  container: '#carousel',
  orientation: 'horizontal',
  items: slides,
  item: { width: 800, height: 400, template: renderSlide },
  scroll: { wheel: false, wrap: true },
}).build();

Keyboard Navigation & Selection #

vlist provides built-in keyboard navigation and single-select behavior out of the box — no features needed. This follows the WAI-ARIA Listbox pattern:

Key Action
Tab Focus the list (activates first or last-focused item)
↑ / ↓ Move focus ring (no selection change)
Page Up / Page Down Move focus by one page
Home / End Move focus to first / last item
Space / Enter Toggle selection on the focused item
Click Select + focus the clicked item

Smart edge-scroll — the viewport only scrolls when the focused item is outside the visible area, aligning to the nearest edge.

Focus ring — the outline only appears during keyboard navigation. Mouse clicks show the selection highlight but no focus ring.

For multi-select, programmatic selection API, or selection:change events, add withSelection():

import { vlist, withSelection } from 'vlist'

const list = vlist({ ... })
  .use(withSelection({ mode: 'multiple' }))
  .build()

list.on('selection:change', ({ selected }) => {
  console.log('Selected:', selected)
})

See Selection for the full feature documentation.


Horizontal Scrolling #

Set orientation: 'horizontal' and provide width instead of (or alongside) height:

const timeline = vlist({
  container: '#timeline',
  orientation: 'horizontal',
  items: events,
  item: {
    width: (i) => events[i].duration * 40,  // variable width
    height: 200,
    template: (event) => `<div class="event">${event.title}</div>`,
  },
}).build();

All features — withGrid(), withGroups(), withMasonry() — work in both vertical and horizontal orientations.


Reverse Mode #

reverse: true anchors the scroll position to the bottom of the list. appendItems auto-scrolls if the user is already at the bottom; prependItems preserves the current scroll position. Useful for any bottom-anchored content: chat, logs, activity feeds, timelines.

import { vlist } from 'vlist';

const chat = vlist({
  container: '#messages',
  reverse: true,
  items: messages,  // oldest first
  item: {
    height: (i) => messages[i].height || 60,
    template: (msg) => `
      <div class="message message--${msg.sender}">
        <p>${msg.text}</p>
      </div>
    `,
  },
}).build();

// New message: auto-scrolls to bottom if user was already there
chat.appendItems([newMessage]);

// Load history: preserves the user's scroll position
chat.prependItems(olderMessages);

See Chat Interface Tutorial for the full scrolling contract and edge cases.


Data Methods #

list.setItems(items)              // Replace entire dataset
list.appendItems(items)           // Add to end
list.prependItems(items)          // Add to start (preserves scroll)
list.updateItem(index, partial)   // Merge update by index
list.removeItem(id)               // Remove by ID → boolean
list.getItemAt(index)             // Get item at index → T | undefined
list.getIndexById(id)             // Get index by item ID → number

Scroll Methods #

// Jump (instant)
list.scrollToIndex(100)
list.scrollToIndex(100, 'center')         // 'start' | 'center' | 'end'

// Animated
list.scrollToIndex(100, { align: 'center', behavior: 'smooth', duration: 300 })

// Cancel in-progress smooth scroll
list.cancelScroll()

// Read position
list.getScrollPosition()                  // pixels from top (or left)

// Properties (readonly)
list.element                              // root DOM element
list.items                                // current items array
list.total                                // total item count

// Snapshots — requires withSnapshots()
const snapshot = list.getScrollSnapshot() // { index, offsetInItem }
list.restoreScroll(snapshot)

Events #

const off = list.on('scroll', ({ scrollPosition, direction }) => { ... })
list.on('item:click', ({ item, index, event }) => { ... })
list.on('range:change', ({ range }) => { ... })  // range = { start, end }

off()  // unsubscribe
list.off('scroll', handler)  // or unsubscribe by reference

See API Reference for all events.


TypeScript #

Pass your item type as a generic — all methods and events are fully typed:

import { vlist, type VList } from 'vlist';

interface Message {
  id: string;
  text: string;
  sender: 'me' | 'them';
  height: number;
}

const chat: VList<Message> = vlist<Message>({
  container: '#chat',
  reverse: true,
  items: [] as Message[],
  item: {
    height: (i) => messages[i].height,
    template: (msg: Message) => `<div>${msg.text}</div>`,
  },
}).build();

// Fully typed:
chat.on('item:click', ({ item }) => {
  console.log(item.sender);  // TypeScript knows this is 'me' | 'them'
});

Lifecycle #

list.destroy()  // Removes DOM, unbinds all listeners, cleans up features

Always call destroy() when unmounting (SPA route changes, component teardown).


Next Steps #

I want to… Go to
Add spacing between items or padding around the list Gap & Padding
Use React, Vue, Svelte, or SolidJS Framework Adapters
Add a grid layout Grid Feature
Group items with headers Groups Feature
Load data from an API Async Feature
Add item selection Selection Feature
Handle 1M+ items Scale Feature
Use a custom scrollbar Scrollbar Feature
Scroll the whole page Page Feature
Build a chat UI Chat Interface
Tune for performance Optimization
Customise styles Styling
Complete API API Reference