Getting Started #
Install vlist, configure your first list, and understand the core API.
Installation #
npm install @floor/vlist
# or: bun add @floor/vlist | pnpm add @floor/vlist | yarn add @floor/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 '@floor/vlist';
import '@floor/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.
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); // Required for vertical
width?: number | ((index: number) => number); // Required for horizontal
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'
reverse?: boolean; // Bottom-anchored content (default: false)
classPrefix?: string; // CSS class prefix (default: 'vlist')
ariaLabel?: string; // Accessible label for the list element
}
Scroll Configuration #
The scroll key controls the scroll system behaviour. All fields are optional.
scroll?: {
element?: HTMLElement | Window; // Override the scroll container
wheel?: boolean; // Enable mouse wheel (default: true)
wrap?: boolean; // Circular navigation (default: false)
scrollbar?: 'none' | 'native'; // 'none' = no scrollbar, 'native' = browser default
idleTimeout?: number; // ms of no-scroll before 'idle' event fires (default: 150)
}
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, scrollbar: 'none' },
}).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();
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();
Restrictions: withGrid(), withGroups(), and reverse: true require vertical orientation.
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 '@floor/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(id, partialItem) // Merge update by ID → void
list.removeItem(id) // Remove by ID → void
Scroll Methods #
// Jump (instant)
list.scrollToIndex(100)
list.scrollToIndex(100, 'center') // 'start' | 'center' | 'end'
// Animated
list.scrollToIndex(100, { align: 'center', behavior: 'smooth', duration: 300 })
// Read position
list.getScrollPosition() // pixels from top (or left)
// Snapshots — save/restore scroll position across navigation
const snapshot = list.getScrollSnapshot() // { index, offsetInItem }
list.restoreScroll(snapshot)
Events #
const off = list.on('scroll', ({ scrollTop, 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 '@floor/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 |
|---|---|
| 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 |