The virtual list library for every framework. Ultra efficient, batteries-included, and accessible with composable plugins — in 8.0 KB.
v2.1.1 — Changelog · Native groups + table + data + grid cross-plugin integration, pixel-perfect snapshot restore, keyboard nav fixes.
- Accessible — WAI-ARIA, 2D keyboard navigation, focus recovery, screen-reader DOM ordering
- Zero dependencies — framework-agnostic core with tiny adapters for Vue, Svelte, Solid, React
- 8.0 KB gzipped — composable plugins with perfect tree-shaking
- Constant memory — ~0.1 MB overhead at any scale, from 10K to 1M+ items
- Tree, grid, masonry, table, groups, data, selection, sortable, transition, scale — all opt-in
- Axis-neutral — vertical and horizontal scrolling through a single code path, all plugins work in both orientations
18 interactive examples, docs & benchmarks → vlist.io
| vlist | TanStack Virtual | react-virtuoso | virtua | vue-virtual-scroller | |
|---|---|---|---|---|---|
| A11y built-in | WAI-ARIA + 2D keyboard | None (DIY) | Partial | Minimal | None |
| Grid + Masonry + Table | All | Grid only | Grid + Table | Grid only | None |
| Vue | 0.6 KB adapter | Yes | — | Yes | 11.8 KB |
| Svelte | 0.5 KB adapter | Yes | — | Yes | — |
| Solid | 0.5 KB adapter | Yes | — | Yes | — |
| Vanilla JS | Native | Yes | — | — | — |
| Constant memory | ~0.1 MB at 1M | No | No | No | No |
| Framework | Package | Size |
|---|---|---|
| Vanilla JS | vlist |
Native — no adapter needed |
| Vue | vlist-vue |
0.6 KB gzip |
| Svelte | vlist-svelte |
0.5 KB gzip |
| SolidJS | vlist-solidjs |
0.5 KB gzip |
| React | vlist-react |
0.6 KB gzip |
npm install vlist # vanilla JS
npm install vlist vlist-vue # or vlist-svelte / vlist-solidjs / vlist-reactimport { createVList } from 'vlist'
import 'vlist/styles'
const list = createVList({
container: '#my-list',
items: [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' },
],
item: {
height: 48,
template: (item) => `<div>${item.name}</div>`,
},
})
list.scrollToIndex(10)
list.setItems(newItems)
list.on('item:click', ({ item }) => console.log(item))Start with the base, add only what you need:
import { createVList, grid, groups, selection } from 'vlist'
const list = createVList({
container: '#app',
items: photos,
item: { height: 200, template: renderPhoto },
}, [
grid({ columns: 4, gap: 16 }),
groups({
getGroupForIndex: (i) => photos[i].category,
header: { height: 40, template: (cat) => `<h2>${cat}</h2>` },
}),
selection({ mode: 'multiple' }),
])| Plugin | Size | Description |
|---|---|---|
| Base | 8.0 KB | Virtualization, ARIA, keyboard nav, gap, padding |
data() |
+4.7 KB | Lazy loading with velocity-aware fetching |
selection() |
+2.5 KB | Single/multiple selection with 2D keyboard nav |
scale() |
+3.9 KB | 1M+ items via scroll compression |
groups() |
+4.8 KB | Sticky/inline headers with grid + table + data integration |
autosize() |
+0.8 KB | Auto-measure items via ResizeObserver |
scrollbar() |
+2.0 KB | Custom scrollbar UI |
grid() |
+2.8 KB | 2D grid layout |
masonry() |
+3.7 KB | Pinterest-style masonry with lane-aware keyboard nav |
table() |
+6.1 KB | Data table with columns, resize, sort |
tree() |
+5.3 KB | Collapsible tree with async loading and indent guides |
page() |
+0.8 KB | Window-level scrolling |
sortable() |
+2.9 KB | Drag-and-drop reordering with auto-scroll |
snapshots() |
+1.3 KB | Scroll position save/restore |
transition() |
+1.8 KB | FLIP-based enter/exit animations for insert & remove |
More examples at vlist.io.
import { createVList, table, selection } from 'vlist'
const myTable = createVList({
container: '#my-table',
items: contacts,
item: { height: 36, template: () => '' },
}, [
table({
columns: [
{ key: 'name', label: 'Name', width: 200, sortable: true },
{ key: 'email', label: 'Email', width: 260, sortable: true },
{ key: 'role', label: 'Role', width: 160, sortable: true },
{ key: 'status', label: 'Status', width: 100, align: 'center' },
],
rowHeight: 36,
headerHeight: 36,
resizable: true,
}),
selection({ mode: 'single' }),
])
myTable.on('column:sort', ({ key, direction }) => { /* re-sort data */ })
myTable.on('column:resize', ({ key, width }) => { /* persist widths */ })import { createVList, grid, scrollbar } from 'vlist'
const gallery = createVList({
container: '#gallery',
items: photos,
item: {
height: 200,
template: (photo) => `
<div class="card">
<img src="${photo.url}" />
<span>${photo.title}</span>
</div>
`,
},
}, [
grid({ columns: 4, gap: 16 }),
scrollbar({ autoHide: true }),
])import { createVList, transition, selection } from 'vlist'
const list = createVList({
container: '#playlist',
items: tracks,
item: { height: 64, template: renderTrack },
}, [
transition({ duration: 200 }),
selection({ mode: 'multiple' }),
])
// Single item — collapses with fade-out, siblings slide up
list.removeItem(trackId)
// Batch — all items animate simultaneously
list.removeItems(list.getSelected())
// Insert — expands in, siblings slide down
list.insertItem({ id: 42, title: 'New Track' }, 0)import { createVList, data } from 'vlist'
const list = createVList({
container: '#list',
item: {
height: 64,
template: (item) => item
? `<div>${item.name}</div>`
: `<div class="placeholder">Loading…</div>`,
},
}, [
data({
adapter: {
read: async ({ offset, limit }) => {
const res = await fetch(`/api/users?offset=${offset}&limit=${limit}`)
const data = await res.json()
return { items: data.items, total: data.total, hasMore: data.hasMore }
},
},
}),
])Every vlist is accessible by default following the WAI-ARIA listbox pattern:
- Arrow keys move focus between items with a visible focus ring
- 2D navigation in grids and masonry — Up/Down by row, Left/Right by cell
- Masonry lane-aware nav — arrows stay in the same visual column
- Home/End, PageUp/PageDown, Ctrl+Home/End — full keyboard coverage
- Screen-reader DOM ordering — items reordered on scroll idle for correct reading order
- Focus recovery — maintains focus when items are removed
Set interactive: false for display-only lists (log viewers, activity feeds) where items contain their own interactive elements.
const list = createVList(config, [plugin1(), plugin2()])| Method | Description |
|---|---|
list.setItems(items) |
Replace all items |
list.appendItems(items) |
Add to end (auto-scrolls in reverse mode) |
list.prependItems(items) |
Add to start (preserves scroll position) |
list.updateItem(id, partial) |
Update a single item by ID |
list.insertItem(item, index?) |
Insert at index (animated with transition) |
list.removeItem(id) |
Remove by ID (animated with transition) |
list.removeItems(ids) |
Batch remove (simultaneous animations) |
list.getItemAt(index) |
Get item at index |
list.getIndexById(id) |
Get index by item ID |
| Method | Description |
|---|---|
list.scrollToIndex(i, align?) |
Scroll to index ('start' | 'center' | 'end') |
list.scrollToIndex(i, opts?) |
With { align, behavior: 'smooth', duration } |
list.getScrollPosition() |
Current scroll offset |
| Method | Description |
|---|---|
list.select(...ids) |
Select item(s) |
list.deselect(...ids) |
Deselect item(s) |
list.toggleSelect(id) |
Toggle |
list.selectAll() / list.clearSelection() |
Bulk operations |
list.getSelected() |
Array of selected IDs |
list.getSelectedItems() |
Array of selected items |
list.on() returns an unsubscribe function. You can also use list.off(event, handler).
list.on('scroll', ({ scrollPosition, direction }) => {})
list.on('range:change', ({ range }) => {})
list.on('item:click', ({ item, index, event }) => {})
list.on('item:dblclick', ({ item, index, event }) => {})
list.on('selection:change', ({ selectedIds, selectedItems }) => {})
list.on('load:start', ({ offset, limit }) => {})
list.on('load:end', ({ items, offset, total }) => {})
list.on('load:error', ({ error, offset, limit }) => {})
list.on('sort:end', ({ fromIndex, toIndex }) => {})
list.on('sort:cancel', ({ originalItems }) => {})| Property | Description |
|---|---|
list.element |
Root DOM element |
list.items |
Current items (readonly) |
list.total |
Total item count |
list.destroy() |
Cleanup and remove from DOM |
Each plugin's config is fully typed — hover in your IDE for details.
grid({ columns: 4, gap: 16 })
masonry({ columns: 4, gap: 16 })
groups({ getGroupForIndex, header: { height, template }, sticky?: true })
selection({ mode: 'single' | 'multiple', initial?: [...ids] })
data({ adapter: { read }, loading?: { cancelThreshold? } })
table({ columns, rowHeight, headerHeight?, resizable? })
autosize() // auto-measure items (requires estimatedHeight)
scale() // auto-activates at 16.7M px
scrollbar({ autoHide?, autoHideDelay?, minThumbSize? })
transition({ duration?: 200, insert?: timing, remove?: timing })
sortable({ handle?: '.drag-handle' }) // drag-and-drop reordering
page() // no config — uses document scroll
snapshots({ autoSave: 'key' }) // automatic sessionStorage save/restoreFull configuration reference → vlist.io
| Option | Default | Description |
|---|---|---|
overscan |
3 |
Extra items rendered outside viewport |
ariaLabel |
— | Accessible label for the listbox |
orientation |
'vertical' |
'vertical' or 'horizontal' scroll direction |
padding |
0 |
Content inset — number, [v, h], or [top, right, bottom, left] |
interactive |
true |
Enable built-in keyboard navigation |
reverse |
false |
Reverse mode for chat UIs |
import 'vlist/styles' // core (always required)
import 'vlist/styles/grid' // when using grid()
import 'vlist/styles/masonry' // when using masonry()
import 'vlist/styles/table' // when using table()
import 'vlist/styles/extras' // optional (variants, loading states, animations)Dark mode works out of the box via prefers-color-scheme, Tailwind's .dark class, or data-theme-mode="dark". Override CSS custom properties to match your design system. See vlist.io/tutorials/styling for the full guide.
| Dataset Size | After Render | Scroll Delta |
|---|---|---|
| 10K items | 0.07 MB | ~0 MB |
| 100K items | 0.08 MB | ~0 MB |
| 1M items | 0.09 MB | 0.19 MB |
- Initial render: ~2ms (constant, regardless of item count)
- Scroll: 120 FPS at any scale
- DOM nodes: ~26 in document with 100K items (visible + overscan only)
Live benchmarks against 9 competitors → vlist.io/benchmarks
Fully typed. Generic over your item type:
import { createVList, grid, type VList } from 'vlist'
interface Photo { id: number; url: string; title: string }
const list: VList<Photo> = createVList<Photo>({
container: '#gallery',
items: photos,
item: {
height: 200,
template: (photo) => `<img src="${photo.url}" />`,
},
}, [grid({ columns: 4 })])v2 is a ground-up rewrite — simpler API, 55% smaller base bundle, zero-allocation scroll path. Full announcement →
| v1 | v2 |
|---|---|
vlist(config).use(withGrid()).build() |
createVList(config, [grid()]) |
withGrid, withSelection, … |
grid, selection, … |
VListFeature |
VListPlugin |
BuilderContext |
PluginContext |
.vlist-items |
.vlist-content |
The instance API (setItems, scrollToIndex, on, destroy) is unchanged.
- Fork → branch → make changes → add tests → pull request
- Run
bun testandbun run buildbefore submitting
- Docs & Examples: vlist.io
- Staging: staging.vlist.io
- GitHub: github.com/floor/vlist
- NPM: vlist
- Issues: GitHub Issues
Built by FloorIO