vlist
v3.3.0
Published
Lightweight, high-performance virtual list with zero dependencies
Maintainers
Readme
vlist
The virtual list library for every framework. Ultra efficient, batteries-included, and accessible with composable plugins.
v3.3.0 — scroll.mode on the one vlist entry: the default, "auto", scrolls natively and hands a list past the browser's size limit to synthetic input, which draws its own scrollbar. Vue, Svelte, Solid and React entries in the package itself (vlist/vue, vlist/svelte, vlist/solid, vlist/react), feature-first and tree-shaken like the vanilla builder; the separate adapter packages are deprecated. See the changelog.
- Accessible —
a11y()or a selection mode adds WAI-ARIA, 2D keyboard navigation, focus recovery, screen-reader DOM ordering - Zero dependencies — framework-agnostic core, with Vue, Svelte, Solid and React entries in the same package
- 10.5 KB gzipped — composable plugins with perfect tree-shaking (Bundlephobia's badge measures the root entry with every plugin at once)
- Constant memory — ~0.1 MB overhead at any scale, from 10K to 1M+ items
- Axis-neutral — vertical and horizontal scrolling through a single code path, all plugins work in both orientations
Install
npm install vlist
npm install vlist@2 # the 2.x line, still maintainedQuick Start
import { 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>`,
},
})Add plugins as the second argument:
import { createVList, grid, selection } from 'vlist'
const list = createVList({ container: '#app', items, item: { height: 200, template: render } }, [
grid({ columns: 4, gap: 16 }),
selection({ mode: 'multiple' }),
])Scroll input
scroll.mode chooses who owns scroll input, on the one vlist entry. Plugins come from vlist in every mode.
| scroll.mode | Input | Use |
|---|---|---|
| "auto" (default) | Native; past the browser's element size limit (16,000,000 px of content) the list hands itself to synthetic input in place, and takes native input back below 12,000,000 px | Every list: nothing to choose, however large it grows |
| "native" | Always native | Browser scrolling only; past the limit the last rows are out of reach, and content:size:overflow says so |
| "synthetic" | vlist owns wheel, touch and keys from the start | Application-owned touch motion |
Native input keeps browser scrollbars, native touch momentum, boundary handoff and native assistive-technology scrolling. The synthetic driver is a separate file, synthetic-driver.js, downloaded the first time a list needs it; until it arrives the list scrolls natively. Each handoff emits scroll:mode.
import { createVList } from 'vlist'
import 'vlist/styles'
const list = createVList({
container: '#my-list',
items: Array.from({ length: 1000 }, (_, id) => ({ id, name: `Row ${id}` })),
item: { height: 48, template: item => `<div>${item.name}</div>` },
scroll: { mode: 'synthetic' },
})| Entry | Use |
|---|---|
| vlist | Every list; input chosen by scroll.mode |
| vlist/synthetic | Deprecated: scroll: { mode: "synthetic" }. Kept through 3.x; it bundles the driver instead of loading it |
| vlist/native | Deprecated alias of vlist |
Synthetic input
A synthetic list draws its own scrollbar, since the browser draws none for content the size of its viewport: the scrollbar() plugin's, loaded with the driver. scroll.scrollbar options configure it, "none" skips it, and a list with scrollbar() keeps that one. scroll.scrollbar: "native" asks for the browser's scrollbar, so mode: "synthetic" rejects it.
page() scrolls the document natively in every mode. Its content must fit the 16,777,216 px document element limit; creation throws above that limit when the size is known, and later growth warns once. A deferred custom renderer whose size is first committed during rendering also warns once. Use viewport scrolling ("auto" or "synthetic") for larger lists.
carousel() runs on its own wrap runway with "auto" and "native", and on the synthetic handler with "synthetic": folds preserve touch motion and smooth snapping across whole laps. The first carousel on a page starts on its runway and switches in place once the driver loads and the carousel is idle. page() and carousel remain incompatible.
Synthetic input limitations:
- Horizontal RTL lists throw at creation, in every mode. Vertical lists and tables support
dir="rtl", including cross-axis wheel movement, aligned table headers and keyboard column navigation. - Same-axis touch stops at either boundary with no parent handoff, including gestures starting inside an edge-pinned list. Use
"native"when boundary gestures must scroll the parent page. - Inertia initializes its frame clock on the first frame after release, adding up to one frame of release latency.
- Wheel input at an edge is left to the page when it cannot move the list. Native cross-axis scrolling remains available.
Measurement corrections from autosize preserve ongoing synthetic motion. Existing plugin conflicts still apply. See the scroll input contract and RFC-015.
Sortable gestures
sortable() works in every scroll mode. Mouse and trackpad dragging still starts after dragThreshold (5 px by default). Touch and pen without a handle use touchDelay, a 350 ms long press; moving at least dragThreshold pixels in any direction before it completes yields to scrolling. This delay separates a deliberate hold from a quick flick. With handle, dragging starts at the threshold without waiting; touches elsewhere scroll.
sortable({ touchDelay: 350, dragThreshold: 5 })
sortable({ handle: '.drag-handle' })A touch during scrolling catches the motion and does not arm a hold for that contact. Lift and press again once the list is at rest. A second finger or pointercancel cancels the pending press or active drag. Edge auto-scroll uses the list's logical position and stops on drop.
The touch-only .vlist-item--touch-sort class suppresses selection and callouts on the pressed item. A claimed touch/pen drag adds .vlist-sort-ghost--touch to the ghost as a visual cue; override that class in CSS to customize it. Keyboard reordering remains Space to grab/drop, arrows to move and Escape to cancel.
Migrating to 3.0
3.0 scrolls natively by default, as 2.x did. Since 3.1, scroll.mode chooses the input on the one vlist entry, and its default, "auto", hands huge lists to synthetic input by itself. vlist/synthetic and vlist/native are deprecated aliases; vlist/native its NativeScrollConfig and NativeCreateVListConfig types alias the standard ScrollConfig and CreateVListConfig.
| 2.x option or API | 3.0 replacement |
|---|---|
| scroll.mode | Since 3.1: "auto" (default), "native" or "synthetic". The 2.x "bounded" is refused. |
| scroll.runway | Remove it. Huge lists need nothing: the default scroll.mode, "auto", hands them to synthetic input. |
| scroll.scrollbar: "native" or "none" | Retained. "native" needs native input; a synthetic list draws its own scrollbar, which "none" skips. |
| PluginContext.setScrollFns, disableDefaultScroll | Use setScrollSource to supply an external position source and commit callback. |
| scale() and ScalePluginConfig | Remove them; the default scroll.mode covers the full logical range. Config no longer installs a scale stub. |
scroll.runway and the bounded mode throw a migration error before creating DOM. vlist/config wires only what the config asks for: selection, the custom scrollbar, snapshots and a11y are each opt-in, so an adapter list matches a core list built from the same options. It keeps the scrollbar options convenience and its top-level scrollbar: "none"; pass scrollbar: true for the overlay scrollbar earlier versions added to every list. baseOffset stays private engine state; plugins use ctx.scroll.getRenderOrigin(). Custom wrap providers supply their handler factory as the second argument of ctx.scroll.setWrap (named setBoundedWrap in 3.0.0, still accepted).
Plugins
| Entry / export | Minified | Gzipped |
|---|---:|---:|
| Base (vlist) | 27.9 KB | 10.5 KB |
| vlist/synthetic (deprecated) | 34.8 KB | 13.1 KB |
| vlist/native (alias) | 27.9 KB | 10.5 KB |
| a11y() | 32.9 KB | 12.4 KB |
| selection() | 38.4 KB | 13.8 KB |
| data() | 41.5 KB | 15.3 KB |
| scrollbar() | 36.1 KB | 13.4 KB |
| sortable() | 39.8 KB | 14.1 KB |
| groups() | 44.2 KB | 15.9 KB |
| page() | 30.4 KB | 11.4 KB |
| snapshots() | 31.2 KB | 11.6 KB |
| transition() | 34.6 KB | 12.5 KB |
| autosize() | 32.3 KB | 12.1 KB |
| grid() | 35.4 KB | 13.1 KB |
| table() | 47.0 KB | 16.6 KB |
| masonry() | 40.1 KB | 14.9 KB |
| tree() | 43.7 KB | 15.8 KB |
| search() | 37.5 KB | 13.8 KB |
| carousel() | 43.9 KB | 16.0 KB |
| vlist/synthetic + carousel() | 50.0 KB | 18.3 KB |
| vlist/synthetic + sortable() | 46.7 KB | 16.7 KB |
| vlist/vue useVList | 28.3 KB | 10.7 KB |
| vlist/svelte vlist | 28.1 KB | 10.6 KB |
| vlist/solid createVList | 28.2 KB | 10.6 KB |
| vlist/react useVList | 28.3 KB | 10.7 KB |
| vlist/react + grid() | 35.8 KB | 13.3 KB |
Sizes are tree-shaken totals from bun run size, not additive plugin costs. Plugin rows include the native default factory plus that plugin; framework rows add the entry, with the framework itself external. The base is 10,735 bytes gzipped, within the 10.59 KB budget (10,844 bytes). bun run size fails if a scenario fails to build, an unused plugin leaks into a bundle, or any published size exceeds its gzip budget. Synthetic input is 13,402 bytes before plugins; with scroll.mode, a vlist list downloads the driver and its scrollbar as synthetic-driver.js (6.2 KB gzipped) only when it goes synthetic. The alias row measures the same source factory; the distributed alias re-exports it without duplicating the implementation.
Bundlephobia's badge measures the package's root entry, which re-exports every plugin, minified and gzipped as one bundle. With a tree-shaking bundler, an app that imports named exports from vlist bundles the base (10.5 KB gzipped) and the features it uses; the table's rows are each a combined size, not amounts to add up.
table()'s estimatedRowHeight is deprecated in 3.3.0 and will be removed in 4.0
(#345). It remains accepted but does not size or measure rows; non-production
builds warn once per table() call. Use rowHeight for fixed rows or a rowHeight
function with the remeasureItem(index) API in 3.3.0 for heights that
change after creation (#347). Table rows are not automatically measured and
table() remains incompatible with autosize().
Frameworks
Vue, Svelte, Solid and React each have an entry in the vlist package itself. Like the vanilla builder, they are feature-first: you pass plugins from vlist, so an app only bundles the features it uses. Every entry imports the core from vlist, and none carries a copy of its own.
| Framework | Import | Entry size |
|-----------|--------|------------|
| Vue 3 | vlist/vue | 0.4 KB gzip |
| Svelte 4 and 5 | vlist/svelte | 0.3 KB gzip |
| SolidJS | vlist/solid | 0.3 KB gzip |
| Vanilla JS | vlist | — |
| React 17+ | vlist/react | 0.4 KB gzip |
npm install vlistFrameworks are optional peer dependencies, so install only the one you use.
Vue
<script setup lang="ts">
import { useVList } from "vlist/vue";
import { grid } from "vlist";
const { containerRef, instance } = useVList(
{ items, item: { height: 200, template: renderPhoto } },
[grid({ columns: 4, gap: 16 })],
);
</script>
<template><div ref="containerRef" style="height: 600px" /></template>If you pass a ref as the config, the list updates when its items change. useVListEvent(instance, "item:click", handler) subscribes to an event for the component's lifetime.
Svelte
<script>
import { vlist } from "vlist/svelte";
import { grid } from "vlist";
</script>
<div
style="height: 600px"
use:vlist={{
config: { items, item: { height: 200, template: renderPhoto } },
plugins: [grid({ columns: 4, gap: 16 })],
onInstance: (list) => (instance = list),
}}
/>Solid
import { createVList } from "vlist/solid";
import { grid } from "vlist";
const { setRef, instance } = createVList(
() => ({ items: items(), item: { height: 200, template: renderPhoto } }),
[grid({ columns: 4, gap: 16 })],
);
return <div ref={setRef} style={{ height: "600px" }} />;createVList keeps the Solid primitive idiom. In a file that also uses the core builder, alias one of the two: import { createVList as createSolidVList } from "vlist/solid".
React
import { useVList, useVListEvent } from "vlist/react";
import { grid } from "vlist";
function Photos({ items }) {
const { containerRef, instanceRef } = useVList(
{ items, item: { height: 200, template: renderPhoto } },
[grid({ columns: 4, gap: 16 })],
);
useVListEvent(instanceRef, "item:click", ({ item }) => open(item));
return <div ref={containerRef} style={{ height: 600 }} />;
}Plugins are read once, at mount; changing them means remounting the list (in React, with a key). An items change updates the list in place. scroll.mode and other structural options also take effect only at mount.
Migrating from vlist-vue, vlist-svelte, vlist-solidjs or vlist-react. Those packages are deprecated. Change the import path, and pass features as plugins instead of config fields:
| Deprecated package | Use |
|---|---|
| vlist-vue | vlist/vue |
| vlist-svelte | vlist/svelte |
| vlist-solidjs | vlist/solid |
| vlist-react | vlist/react |
// before
import { useVList } from "vlist-react";
useVList({ items, item, selection: { mode: "single" } });
// after
import { useVList } from "vlist/react";
import { selection } from "vlist";
useVList({ items, item }, [selection({ mode: "single" })]);Their 3.1 releases will be built on these entries and keep the config-based API, so existing code keeps working while you migrate.
Docs & Examples
18 interactive examples, full API reference, tutorials, and live benchmarks → vlist.io
Migrating from v1
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.
License
Acknowledgments
Thanks to Alexander Klaiber for graciously transferring the vlist package name on npm.
