Scroll modes #
A vlist list moves in one of two ways. They differ in who owns the scroll position and
how large the content element gets. scroll.mode chooses between them, and its default
chooses for you. Plugins and the public API are the same either way.
| Input | Owner of the position | Content element | List size | Best for |
|---|---|---|---|---|
| Native | The browser viewport | Full virtual size | Up to the browser's element size limit, about 16 million px | Native scrollbar, native touch momentum, parent scroll handoff, find-in-page |
| Synthetic | vlist, from pointer, wheel and keyboard events | The viewport itself | Unbounded | Huge lists and application-owned touch motion |
import { createVList } from "vlist";
const list = createVList({
container: "#app",
items,
item: { height: 48, template },
scroll: { mode: "auto" }, // the default: "auto" | "native" | "synthetic"
});
scroll.mode |
Input |
|---|---|
"auto" (default) |
Native. Past 16,000,000 px of content the list hands its input to the synthetic handler in place, and takes native input back below 12,000,000 px |
"native" |
Always native. Past the limit the last rows are out of reach, and the list says so |
"synthetic" |
Synthetic from the start |
The synthetic driver is not in the vlist bundle. It is a separate file,
synthetic-driver.js (about 3.4 KB gzipped), downloaded the first time a list needs it.
A list that stays native never downloads it; until it arrives, a list scrolls natively.
Bounded mode, scroll.runway and scale() were removed in 3.0 and are still refused.
scroll.mode came back in 3.0.x as this choice of input
(RFC-015). See
Migration: v2 to v3, or the 2.x scroll modes page.
Native #
The list is a normal scrolling element: rows sit inside a content element sized to the
full virtual height, and the browser scrolls it. Everything the browser does with a
scroller keeps working, including the native scrollbar, find-in-page and handing momentum
to a parent scroller at the edges. scroll.scrollbar: "none" hides the browser scrollbar,
and scrollbar() replaces it with a custom one.
The limit is the element size the browser will lay out (Chrome stops at 33,554,428 px).
vlist treats 16,000,000 px of content as the limit. With scroll.mode: "native", a list
that grows past it emits an error event once, with the context content:size:overflow,
and the rows past the browser's cap cannot be reached.
Synthetic #
import { createVList, scrollbar } from "vlist";
import "vlist/styles";
const list = createVList({
container: "#app",
items,
item: { height: 48, template },
scroll: { mode: "synthetic" },
}, [scrollbar()]);
vlist owns the position. The viewport clips its content, pointer events (touch-action: pan-x pinch-zoom on vertical lists), wheel and keyboard events feed a small motion model
with exponential-decay inertia, and rows are placed relative to the owned position. There
is no element-size limit (RFC-014).
What changes for you:
- Its own scrollbar. The browser draws none for synthetic content, so the list
draws one: the
scrollbar()plugin's, downloaded with the synthetic driver and mounted when the list goes synthetic (removed if it goes back to native). It applies platform defaults (thin overlay on macOS and Android, classic on Windows), readsscrollbar-widthandscrollbar-colorfrom the container, and is keyboard and screen-reader accessible.scroll.scrollbaroptions configure it,"none"skips it, and a list withscrollbar()keeps that one."native"asks for the browser's scrollbar, somode: "synthetic"rejects it. - Programmatic scrolls commit synchronously.
scrollTo,scrollToIndexand plugin corrections updategetScrollPosition(), render and emitscrollin the call, as with native input. - Plugins: every plugin works with synthetic input, including
sortable().page()scrolls the document, soscroll.modedoes not apply to it.carousel()runs its own loop on its runway with"auto"and"native", and on the synthetic handler with"synthetic". Horizontal lists on right-to-left pages throw at creation in every mode; vertical lists and tables on right-to-left pages are supported. Plugin conflicts are unchanged. - Boundaries: same-axis touch stops at the list's edges without handing off to the
parent page. Use
mode: "native"when boundary gestures must scroll the page. - First frames: with
mode: "synthetic"the driver is downloaded when the list is created, so a wheel or touch in the first moments is still native. The deprecatedvlist/syntheticentry bundles the driver instead, for lists that must be synthetic from their first frame; it adds 2.6 KB gzipped to the base.
Auto #
"auto" is the default because it needs no decision: a list scrolls natively for as long
as the browser can lay it out, and only a list that grows past the limit changes input.
- In place. The list, its DOM, its plugins, the selection, focus and the scroll position stay. Only who owns input and the size of the content element change.
- Never mid-gesture. A swap waits until the list is idle, so a fling or a smooth scroll is never cut off. Until then the browser clamps the list, which vlist renders correctly.
- Jumps land. A
scrollToIndexthe browser could not apply while a swap was pending, such as the last row right aftersetItems, lands where it was asked once the list is synthetic. - Both ways. A list that shrinks below 12,000,000 px takes native input back, on the same row. The gap between the two thresholds keeps a list that hovers around the limit, a filter toggled on and off, from swapping on every change.
- Observable. Each swap emits
scroll:modewith{ mode: "native" | "synthetic" }. - Scrollbar. Past the limit the list has no native scrollbar. Lists that can grow
that large should carry
scrollbar(), which looks the same in both modes.
list.on("scroll:mode", ({ mode }) => console.log(`input is ${mode} now`));
Framework entries #
The framework entries (vlist/vue, vlist/svelte, vlist/solid, vlist/react) pass
scroll to the core. Set the mode like any other option. It takes effect at mount:
import { useVList } from "vlist/react";
const { containerRef } = useVList({
items,
item: { height: 48, template: item => String(item.id) },
scroll: { mode: "synthetic" },
});
Measured on 3.0.0-next.3 #
Vanilla lists, three runs each, median reported. The window was on a MacBook built-in display running at 120 Hz. Browsers: Chrome 153, Chromium 130, Firefox 156, Safari 26.4.
These runs used the vlist and vlist/synthetic entries of that release; the engines
are the ones scroll.mode selects today. Inside one browser, native and synthetic match. The gap between browsers is the frame rate that browser delivered, not a difference between the two entries. Dropped frames were 0% and position lag was 0 px on every scroll that completed.
Initial render #
Median time to create the list, in milliseconds.
| Items | Mode | Chrome | Chromium | Firefox | Safari |
|---|---|---|---|---|---|
| 10K | Native | 0.6 ms | 0.9 ms | 1 ms | 2 ms |
| 10K | Synthetic | 0.6 ms | 0.7 ms | 1 ms | 2 ms |
| 100K | Native | 0.6 ms | 0.8 ms | 2 ms | 2 ms |
| 100K | Synthetic | 0.6 ms | 0.8 ms | 2 ms | 2 ms |
| 1M | Native | 1.5 ms | 3.7 ms | 10 ms | 5 ms |
| 1M | Synthetic | 1.4 ms | 3.8 ms | 12 ms | 5 ms |
Scroll #
The scroll test moves about 36,000 px. It does not walk the whole list, so a native 1M run is not a test of the element-size clamp.
| Items | Mode | Chrome | Chromium | Firefox | Safari |
|---|---|---|---|---|---|
| 10K | Native | 120 fps | 120 fps | 30 fps | 60 fps |
| 10K | Synthetic | 120 fps | 120 fps | 30 fps | 60 fps |
| 100K | Native | 120 fps | 120 fps | 30 fps | 60 fps |
| 100K | Synthetic | 120 fps | 120 fps | 30 fps | 60 fps |
| 1M | Native | 120 fps | 120 fps | — | 60 fps |
| 1M | Synthetic | 120 fps | 120 fps | 30 fps | 60 fps |
Frame time at p95 follows that rate: about 9.2 ms in Chrome and Chromium, 18 ms in Safari, 34 ms in Firefox. Native and synthetic stay on the same figure.
Firefox, native, 1M did not scroll. The benchmark reported that the scroll driver did not move the list, on three attempts. Synthetic at 1M did scroll.
Scroll to an index #
Median time for a scrollTo, in milliseconds. The time depends on the browser and does not depend on the entry or the list length.
| Items | Mode | Chrome | Chromium | Firefox | Safari |
|---|---|---|---|---|---|
| 10K | Native | 41 ms | 41 ms | 166 ms | 81 ms |
| 10K | Synthetic | 41 ms | 41 ms | 166 ms | 81 ms |
| 100K | Native | 42 ms | 41 ms | 166 ms | 81 ms |
| 100K | Synthetic | 41 ms | 41 ms | 166 ms | 82 ms |
| 1M | Native | 42 ms | 41 ms | 167 ms | 80 ms |
| 1M | Synthetic | 41 ms | 41 ms | 166 ms | 82 ms |
Memory #
performance.memory exists in Chrome and Chromium only. Firefox and Safari saved a Memory row with status 0 and no heap numbers. Those rows are not a measurement.
Heap allocated by creating the list, in MB. Native and synthetic match. The resident heap is noisier, because a later GC moves it, so it is a poor comparison.
| Items | Mode | Chrome | Chromium |
|---|---|---|---|
| 10K | Native | 0.07 MB | 0.11 MB |
| 10K | Synthetic | 0.08 MB | 0.13 MB |
| 100K | Native | 0.41 MB | 0.45 MB |
| 100K | Synthetic | 0.43 MB | 0.46 MB |
| 1M | Native | 3.85 MB | 3.88 MB |
| 1M | Synthetic | 3.86 MB | 3.88 MB |
Choosing #
- Most lists: leave the default,
"auto". Native while the browser can lay the list out, synthetic past that, with nothing to configure. - When native behaviour must hold whatever the size (parent scroll handoff,
find-in-page), and you accept the limit:
"native". - Touch-heavy lists that should move the same way on every platform:
"synthetic". - Document scrolling with
page(): native, within the element limit.
Try the modes in the large list example.