Overview
Windowing primitive for long lists. VirtualList owns a native scroll container and
renders only the visible rows plus overscan; you own the row markup through the
row snippet. It scrolls vertically by default and horizontally under
horizontal.
Use VirtualList to window an already-loaded large collection. Use LoadMore to fetch another page as the reader reaches the end of a growing result set; the two can be composed when a paginated collection also needs windowed rendering — or use onEndReached below to drive the fetch from scroll position directly.
For two-dimensional data, reach for DataGrid rather than composing grids out of this: it owns column virtualization and role="grid" semantics. The repository's virtualization guide explains why cinder ships three windowing engines and which one backs each component.
Choosing a row-sizing mode
By default every row is exactly itemHeight pixels tall. That is the fast path:
offsets are pure arithmetic and no row is ever measured. (The component always
observes its own scroll container to track viewport size, in both modes — what
dynamicSize adds is per-row measurement.)
When rows genuinely vary — they wrap, embed media, or hold user content — set
dynamicSize. itemHeight then becomes the initial estimate for rows that have
not been measured yet. Each row is measured once as it mounts, its real size is
cached by key, and when a measurement differs from the estimate the scroll offset
is corrected before paint so the viewport does not visibly jump.
Prefer the fixed path when it is honest. dynamicSize costs a ResizeObserver
subscription per mounted row and a rebuilt offsets table each time a measurement
lands, and it buys nothing for a list whose rows really are uniform.
Pass getKey whenever dynamicSize is on. Measured sizes are cached by key, so
index-derived keys will attribute a cached size to the wrong row the first time
items reorder.
Scrolling along the inline axis
Set horizontal to window a row of items instead of a column. The component then
owns a horizontally-scrolling container, positions rows along the inline axis, and
reads its scroll offset from scrollLeft rather than scrollTop.
Two props are reinterpreted rather than renamed. itemHeight becomes each
item's width, and height becomes the container's inline-size. So does the
--cinder-virtual-list-height custom property, which keeps its name and switches
to driving inline-size. This is deliberate: renaming them would mean either a
second parallel set of props that is meaningless in the other mode, or a breaking
rename for every existing vertical caller. The names stay; read them as "extent
along the main axis" and "extent of the viewport."
Right-to-left
Right-to-left is handled, not assumed. The writing direction is resolved from the
container's computed style at mount, so a dir="rtl" anywhere up the tree is
enough — you do not pass anything extra.
Underneath, this is messier than it looks. In a right-to-left container browsers
have historically disagreed about what scrollLeft even means: whether it starts
at zero or at the maximum, and whether it grows positive or negative as you scroll
away from the start edge. Rather than assume one, the component measures which
convention the browser implements — once per document, with a detached probe — and
normalizes every read and write into a single start-edge-relative offset. Row
positions use logical CSS properties throughout, so nothing depends on physical
left and right.
The practical consequence for you: scrollToIndex, stickToBottom, and the row
context.start offset all mean the same thing in both directions. "The start" is
the left edge in a left-to-right list and the right edge in a right-to-left one.
Usage
Use stickToBottom for live log tails: appending while the user is already at
the bottom keeps the newest row in view, while appending with the viewport
scrolled up leaves the scroll position unchanged.
Scrolling to an item
bind:ref hands back a typed handle with scrollToIndex:
align accepts 'start', 'center', 'end', or 'auto' (the default, which
leaves the position alone when the row is already fully visible). Under
dynamicSize the target accounts for every measured row before it, and re-settles
if rows above it are measured for the first time mid-scroll.
Chat transcripts and infinite scroll
Set reverse for a chat-style list. It opens at the newest item and returns there
whenever one arrives, however far back the reader has scrolled.
reverse names the anchoring, not the ordering. Items stay in their natural
order — oldest at index 0, newest last — and the array is never flipped. This is
worth being explicit about, because the name suggests otherwise.
It is deliberately distinct from stickToBottom, which pins only when the reader is
already at the bottom. Choose by what should happen to someone reading history
when a new message lands: stickToBottom leaves them alone, reverse brings them
to the newest item. When both are set, reverse wins.
Prepending is handled as its own case. Loading a page of older history grows the list above the reader, and the component anchors to the row they were on so it stays put — it does not pin to the end, and it does not leave them silently looking at a different row.
Loading more in both directions
onEndReached fires when the reader comes within overscan items of the end;
onStartReached does the same at the start. Together they give bi-directional
infinite scroll.
Each callback fires once per approach, not once per scroll event, and re-arms when the item count changes. That pairing is what makes the obvious usage safe: appending in response lets the next approach fire, while a source that returns nothing leaves the count unchanged and the callback latched, so it does not spin.
Both callbacks are also evaluated when the item count changes, not only on scroll — an append can bring the end into range without the reader moving at all.
Remembering the scroll position
scrollRestoration writes the reader's position to sessionStorage and restores it
when the list mounts again, so navigating away and back returns them to where they
were.
It needs a scrollRestorationId, and there is deliberately no default. An implicit
key — derived from position in the tree, or mount order — would quietly hand one
list's remembered offset to a different list after an unrelated refactor, and two
lists on one page would overwrite each other. Pick something tied to what the list
shows.
Two details worth knowing. Under dynamicSize the position is restored by index
rather than by pixel offset: at mount every row is still an estimate, so the saved
offset points at a different row than it did when saved — scrolling to the index
lands on the row the reader actually left, and the settle pass follows it as the
rows above are measured. And a saved position whose row no longer exists is dropped
rather than clamped, because clamping would drop the reader somewhere arbitrary and
then save that as though it were their place.
Storage failures are swallowed throughout. A browser in private mode can throw on
reading sessionStorage, not just writing to it, and losing a remembered offset must
never break the list.
Sticky rows
stickyItems names the indexes that pin to the leading edge while the reader
scrolls past them — section headers in a grouped list, most often.
The part that virtualization would otherwise break is keeping them mounted. A row
whose index has left the rendered window is normally unmounted, so the heading would
disappear at exactly the moment it is meant to be pinned. The component keeps the
active sticky row in the DOM past its window, and re-sorts the rendered set by index
so a keyed {#each} does not move it behind the rows that follow it.
Setting stickyItems also makes scrollToIndex header-aware. A pinned header covers
the leading edge, so a row aligned flush to it lands underneath — in a list where the
header and its rows share a height, completely underneath. Destinations are offset by
the header that will cover them, which applies to the component's keyboard navigation
and to scrollToIndex calls of your own.
Invalid entries — duplicates, non-integers, indexes outside the list — are dropped rather than throwing. A bad sticky index is a cosmetic problem, not a correctness one.
Announcing position to assistive technology
Every row carries aria-posinset and aria-setsize. Both describe the full
collection, not the rendered window — without them a screen reader announces what is
mounted, so a 10,000-row list reads as "3 of 12". aria-posinset is 1-based, as the
specification requires, while the row's context.index stays 0-based.
Scrolling behaviour
smoothScroll makes scrollToIndex animate by default. An explicit behavior in
the call still wins, so a single jump-to remains available on a list that otherwise
animates. Scroll corrections under dynamicSize are never animated whatever this is
set to: a smooth correction would visibly perform the jump it exists to hide.
adaptiveOverscan grows overscan while the reader is scrolling fast and shrinks it
back when they slow down. overscan becomes a floor rather than a fixed value, so
turning this on can only ever render more rows than the configured value, never
fewer. The growth is bounded — an unbounded overscan during a fling would mount
thousands of rows and defeat virtualizing at all.
Message 0. This sentence repeats to make the row wrap onto more lines.
Message 1. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines.
Message 2. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines.
Message 3. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines.
Message 4. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines.
Message 5. This sentence repeats to make the row wrap onto more lines.
Message 6. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines.
Message 7. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines.
Message 8. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines.
Message 9. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines. This sentence repeats to make the row wrap onto more lines.
Message 10. This sentence repeats to make the row wrap onto more lines.
When to use
- Rendering thousands of rows: logs, event streams, activity feeds.
- Rows vary in size because they wrap or embed media — dynamicSize measures each.
- Windowing the inline axis — horizontal, which also resolves right-to-left.
- A transcript anchored to its newest message, or paging in at either edge.
- Two-dimensional grids, which need column virtualization and grid semantics. Use Data Grid instead
- A native table or a hierarchy — data-table and tree window those already.
- The collection is small enough to render in full.
Examples
Variable-height rows with dynamicSize
A 5,000 message transcript whose rows wrap to different heights. itemHeight is only the starting estimate; each row is measured once as it mounts and the scroll position is corrected so the viewport never jumps.
Horizontal virtual list
A native horizontally-scrolling container windowing 4,000 columns. When horizontal is set, itemHeight becomes each column's width in pixels and height becomes the container's inline-size instead of its block-size.
Horizontal virtual list (RTL)
The same horizontal windowing as the horizontal example, wrapped in a dir="rtl" ancestor with Arabic row content. The component resolves a right-to-left writing direction from that ancestor and reads its scroll offset from the right edge of the container instead of the left.
Horizontal sticky headers
stickyItems along the inline axis. The pinned header holds at the leading edge exactly as it does vertically, and the list takes over the arrow keys — which are exchanged under a right-to-left writing direction, because KeyboardEvent.key is not remapped by dir.
Bi-directional infinite scroll
onEndReached and onStartReached fire as the reader comes within overscan items of either edge. Each fires once per approach and re-arms when the item count changes, so a source that returns nothing does not spin.
Reverse (chat transcript)
A transcript that opens at its newest message and returns there whenever one arrives, however far the reader has scrolled back. Items stay in natural order — reverse names the anchoring, not the ordering. Prepending older history leaves the reader exactly where they were.
Sticky section headers
stickyItems names the indexes that pin to the leading edge while the reader scrolls past them. A sticky row stays mounted after its own index leaves the rendered window, which is the part plain virtualization would otherwise break: the heading would vanish exactly when it is meant to be pinned.
10,000 item virtual list
A native scroll container rendering only the visible slice of a 10,000 item append-only event stream.
Props
Name | Type | Default | Description |
|---|---|---|---|
items required | Item[] | Items in full logical order. Only the visible window is mounted. | |
itemHeight required | number | Each item's extent in pixels along the axis being scrolled: its height by
default, or its width under horizontal. By default every row is assumed to
be exactly this size. When dynamicSize is true this becomes the initial
estimate for rows that have not been measured yet. | |
dynamicSize | boolean | false | Measure each rendered row with ResizeObserver and cache the result,
instead of assuming every row is exactly itemHeight along the scrolled
axis. Use this when rows wrap, contain images, or otherwise vary in size.
Composes with horizontal, where each row is measured by its width.
Defaults to false. While false, no row is measured, no size is cached,
and no scroll correction runs — the fixed-height path stays the fast path.
The component still observes its own scroll container to track viewport
size, as it always has; that is independent of this prop. |
horizontal | boolean | false | Scrolls and lays rows out along the inline axis instead of the block axis.
itemHeight and height are REINTERPRETED rather than renamed: itemHeight
becomes each item's width in pixels along the main axis, and height becomes
the container's inline-size. The --cinder-virtual-list-height custom property
keeps its name too and switches to driving inline-size, so an existing theme
override keeps working when this is turned on.
Right-to-left is handled: the writing direction is resolved from the container's
computed style at mount, and the scroll offset is read from the start (right)
edge in that case.
Defaults to false. |
overscan | number | 5 | Extra rows rendered before and after the visible window. Defaults to 5. |
height | text | '20rem' | CSS extent of the native scroll container across the axis it scrolls: its
block-size by default, or its inline-size under horizontal.
Defaults to "20rem". |
stickToBottom | boolean | false | When true, appending items while the viewport is already at the bottom keeps the newest item pinned in view. Appending while scrolled up leaves the scroll position unchanged. |
reverse | boolean | false | Chat-transcript behaviour: the list starts at its end and returns there on
every append.
Items stay in their natural order — oldest at index 0, newest last. reverse
names the anchoring, not the ordering, and the array is never flipped.
Deliberately distinct from stickToBottom, which pins only when the reader is
already at the bottom. reverse pins on every append regardless of where the
reader is. When both are set, reverse wins.
Prepending — loading a page of older history — never moves the reader: the row
they were looking at stays put while the list grows above it.
That last guarantee REQUIRES getKey. Telling a prepend from an append means
comparing key sequences, and without getKey the keys are array indexes: a
prepend turns [0, 1, 2] into [0, 1, 2, 3, 4], which is indistinguishable
from an append and pins the reader to the end instead of holding their place.
Pass getKey whenever the list can grow at the front.
Defaults to false. |
onEndReached | (() => void) | undefined | Called when the reader scrolls within overscan items of the end of the list.
Explicitly | undefined rather than merely optional: this package compiles with
exactOptionalPropertyTypes, under which the two differ, and a consumer writing
onEndReached={enabled ? load : undefined} would otherwise fail to typecheck.
Fires once per approach, not once per scroll event, and re-arms when the item
count changes — so appending in response to it allows the next approach to fire
while a source that returns nothing does not spin. | |
onStartReached | (() => void) | undefined | Called when the reader scrolls within overscan items of the start of the
list. Pair with onEndReached for bi-directional infinite scroll.
Latched the same way as onEndReached. Prepending in response preserves the
reader's position rather than jumping to the new start — but only with getKey,
since index-derived keys make a prepend look exactly like an append. | |
scrollRestoration | boolean | false | Remember the scroll position across navigation, keyed by
scrollRestorationId.
The position is written to sessionStorage when the list tears down, and read
back when it mounts. Deliberately not on every scroll: that would mean a
storage write per frame during a fling, and teardown is the last moment the
position is knowable anyway. The consequence is that a tab closed by a crash,
rather than by navigating away, will not have saved.
Storage failures are swallowed: a browser in private mode or at its quota throws
on write — and on read — and losing a remembered offset must not break the list.
Has no effect without a scrollRestorationId — see that prop for why.
Explicitly | undefined, like the other conditionally-supplied props: this
package compiles with exactOptionalPropertyTypes, under which optional and
undefined-valued differ, and scrollRestoration={enabled ? true : undefined}
would otherwise fail to typecheck.
Defaults to false. |
scrollRestorationId | text | Stable id under which scrollRestoration saves this list's position.
Required for restoration to do anything. There is deliberately no default:
an implicit key derived from position or order would silently hand one
list's remembered offset to a different list after a refactor, and two lists
on one page would overwrite each other. Choose something tied to what the
list shows, such as a route or collection name. | |
stickyItems | number[] | Item indexes that stay visible at the leading edge while the reader scrolls past them — section headers in a grouped list, most often. A sticky row is kept mounted even after its own index leaves the rendered window, which is the part virtualization would otherwise break: unmounting it would make the heading vanish exactly when it is meant to be pinned. Indexes outside the list, duplicates, and non-integers are dropped rather than throwing — a bad sticky index is a cosmetic problem, not a correctness one. | |
smoothScroll | boolean | false | Animate scrollToIndex by default instead of jumping.
Equivalent to passing behavior: 'smooth' on every call; an explicit
behavior in the call's options still wins. Scroll corrections under
dynamicSize are never animated whatever this is set to — a smooth
correction would visibly perform the jump it exists to hide.
Defaults to false. |
adaptiveOverscan | boolean | false | Grow overscan while the reader is scrolling fast, and shrink it back when
they slow down.
overscan becomes a floor rather than a fixed value: a fast fling renders
further ahead to cut pop-in, and a stationary list falls back to exactly what
was configured so the DOM stays small. The growth is bounded, because an
unbounded overscan during a fling would mount thousands of rows and defeat the
point of virtualizing at all.
Defaults to false. |
tabindex | number | 0 | Override the default focus behavior. The component sets tabindex="0"
by default so keyboard users can reach the native scroll container for
arrow-key scrolling. Pass tabindex={-1} when the viewport should be
programmatically focusable without entering the tab order. |
getKey | (item: Item, index: number) => VirtualListKey | Stable key extractor. Omit only when items are append-only and never
reordered; the component will fall back to full-array indexes.
Required in practice under dynamicSize: measured sizes are cached by key,
so index-derived keys will mis-attribute cached sizes if items ever reorder. | |
row required | snippet | Rendered row snippet. Receives the item and its virtual row context. | |
role | AriaRole | undefined | null | 'list' | |
onscroll | UIEventHandler<T> | undefined | null | ||
onwheel | WheelEventHandler<T> | undefined | null | ||
onpointerdown | PointerEventHandler<T>undefinednull | ||
ontouchstart | TouchEventHandler<T> | undefined | null | ||
onkeydown | KeyboardEventHandler<T>undefinednull | ||
ref bindable | {
/**
* Scrolls the item at `index` into view. Under `dynamicSize` this accounts for
* the measured size of every row before the target, and re-settles … Show full type{
/**
* Scrolls the item at `index` into view. Under `dynamicSize` this accounts for
* the measured size of every row before the target, and re-settles if rows
* above it are measured for the first time mid-scroll. Out-of-range indexes
* are clamped to the list's bounds.
*/
scrollToIndex: (index: number, options?: VirtualListScrollToIndexOptions) => void;
} | Typed programmatic handle. Use bind:ref to receive it. |