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
idfield (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 isposition: absoluteand 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 |