@scan0815/virtual-scroller
v1.1.7
Published
A virtualized masonry-style multi-column list web component built with Stencil — runs inside an Ionic <ion-content>, a scrollable element, or a normally-scrolling page.
Maintainers
Readme
@scan0815/virtual-scroller
<virtual-scroller> is a standards-based custom element that virtualizes a masonry-style, multi-column list. It keeps only the items currently in (and just outside) the viewport in the DOM, reusing a small pool of nodes as you scroll, so lists with thousands of items stay smooth.
It is built with Stencil and runs inside an Ionic <ion-content>, a scrollable <div>, or a normally-scrolling page — see Scroll container.
Why virtualize?
Every item in a list is a live DOM node the browser has to lay out, paint, and keep in memory — on screen or not. Render 10,000 items the naive way and you build a document roughly 670,000 px tall; scrolling it forces the browser to move all of that at once, and frames start dropping.
<virtual-scroller> mounts only the items in (and just outside) the viewport and recycles a small pool of nodes as you scroll, so the mounted node count tracks the visible window, not the list length — scroll cost stays flat as the list grows.
The bundled benchmark (npm start → bench) scrolls 10,000 variable-height items three ways. A representative run (numbers vary by device, item complexity, and browser):
| Mode | Avg FPS | p50 ms | p95 ms | Max ms | Dropped frames |
| --- | --- | --- | --- | --- | --- |
| naive — all nodes in the DOM | 63 | 19.1 | 21 | 39 | 181 |
| content-visibility: auto | 21 | 50 | 60.6 | 70.8 | 105 |
| <virtual-scroller> | 100 | 10 | 11 | 11.1 | 0 |
content-visibility: auto lets the browser skip rendering off-screen nodes, but all 10,000 still live in the DOM, so layout/containment work and memory keep scaling with the list. Virtualization removes the off-screen nodes entirely, holding frame times at the ~10 ms (100 FPS) budget with zero dropped frames — measure your own case with the bench demo.
Features
- Masonry layout across a responsive number of columns.
- DOM node recycling — the number of mounted nodes tracks the visible window, not the list length.
- Fixed-height or aspect-ratio-driven item heights.
- Responsive padding via a
matchMediaquery. endOfScrollerevent for infinite-scroll / paging.columnLayoutChangedevent exposing the real rendered column width, so you can recompute item heights precisely instead of relying on a static hint.
Scroll container
The component needs something to scroll inside. On connectedCallback it resolves one, in this order:
- the
scrollTargetprop — a CSS selector (resolved withclosest()), an element, or the string'window'; - an ancestor
<ion-content>— its inner scroll element is obtained viagetScrollElement()and it is scrolled withscrollToPoint()(full Ionic support, unchanged); - the nearest scrollable ancestor (an element with
overflow-y: auto | scroll); - otherwise the page itself (
window).
So it works inside an Ionic <ion-content>, inside a plain scrollable <div>, or on a normally-scrolling page — no configuration required in the common cases. Ionic is only needed if you actually use <ion-content>.
// explicit container by selector, element, or the whole page
<virtual-scroller scrollTarget="#my-scroll-box" ...></virtual-scroller>
<virtual-scroller scrollTarget="window" ...></virtual-scroller>
scrollTargetis read once on connect. A selector must resolve to an ancestor of the scroller (its position is measured relative to that container).
Installation
npm install @scan0815/virtual-scrollerUsing a bundler / framework
import { defineCustomElements } from '@scan0815/virtual-scroller/loader';
defineCustomElements();Script tag (CDN)
<script type="module" src="https://unpkg.com/@scan0815/virtual-scroller/dist/virtual-scroller/virtual-scroller.esm.js"></script>Usage
renderItem must return a Stencil VNode, not a DOM node. In a JSX host you write JSX directly; in plain JS you create VNodes with h() from @stencil/core.
⚠️ The component recycles DOM nodes. A node that showed one item is reused for another as you scroll, so images (and other async content) must be cleaned up — give each
<img>akeytied to itssrc, otherwise a recycled card briefly shows the previous item's image until the new one loads. See Important contracts.
In JSX (Stencil / React-style)
<virtual-scroller
minColCount={1}
maxColCount={3}
padding={[8, 24]}
matchMedia="(min-width: 768px)"
items={items}
renderItem={(data) => <div class="card">{(data as MyItem).title}</div>}
onEndOfScroller={() => loadMore()}
onColumnLayoutChanged={(ev) => reflow(ev.detail.elementWidth)}
></virtual-scroller>Imperative (vanilla JS)
<ion-content>
<virtual-scroller id="scroller"></virtual-scroller>
</ion-content>
<script type="module">
import { h } from '@stencil/core';
const scroller = document.getElementById('scroller');
// NOTE: `items` must be mutable — see "Important contracts" below.
scroller.items = Array.from({ length: 5000 }, (_, i) => ({
height: 120,
item: { title: `Item ${i}` },
}));
// renderItem returns a VNode created with h(), not an HTMLElement.
scroller.renderItem = (data) => h('div', { class: 'card' }, data.title);
</script>Without Ionic (window scroll)
No <ion-content> and no Ionic dependency — drop the element on a normally-scrolling page and it virtualizes against the window. scroll-target="window" makes that explicit; if you omit it and there is no <ion-content> or scrollable ancestor, the page is used automatically.
<virtual-scroller id="list" scroll-target="window"></virtual-scroller>
<script type="module">
import { h } from '@stencil/core';
const list = document.getElementById('list');
// NOTE: `items` must be mutable — see "Important contracts" below.
list.items = Array.from({ length: 5000 }, (_, i) => ({
height: 120,
item: { title: `Item ${i}` },
}));
list.renderItem = (data) => h('div', { class: 'card' }, data.title);
</script>For a specific scrollable box instead of the page, use a selector:
scroll-target="#my-scroll-box"(or set the.scrollTargetproperty to the element). See Scroll container.
Column count
The number of columns is derived from the viewport width:
clamp(floor(viewportWidth / approxItemWidth), minColCount, maxColCount)viewPortMaxWidth caps the width used in that calculation.
Styling items
<virtual-scroller> renders with scoped encapsulation (not Shadow DOM). The
items your renderItem callback returns are placed in the light DOM, so your
app-level styles reach them with ordinary descendant selectors:
virtual-scroller user-info-item ion-item {
width: 92%;
}No ::part() and no CSS-variable indirection is required — this is exactly the CSS
you would write for any element on the page. Global styles cascade in normally, and
Ionic CSS variables reach the ion-* components inside your items.
The component's own styles (:host, .virtual-item) stay scoped — Stencil
auto-prefixes them with a scope class, so they never leak onto the rest of your app.
Item / column width. The width of each item wrapper is still computed by the component (from
minColCount/maxColCount/sidePaddingand the per-itemwidthhint) and set inline. Prefer those props to size the columns; use CSS for the content inside each item.
Important contracts
itemsmust be mutable. The component writes layout numbers (translateX,translateY,calculatedWidth,calculatedHeight,index) back onto the objects you pass. Do not pass a frozen array or immutably-managed objects — layout will silently break. Assign a fresh array to trigger a recalculation (e.g.scroller.items = [...scroller.items]).renderItemis required. It is typed optional but the component cannot render without it. It receives theitempayload (the value ofIVItem.item) and must return a Stencil VNode (or array of VNodes) — created via JSX orh()from@stencil/core. A raw DOM node will not render.Item height comes from
item.height(fixed pixels) or, whenitem.aspectis set, is computed from the current column width divided by the aspect ratio.Reset async content in
renderItem. The component keeps a small pool of DOM nodes and recycles them as you scroll — a node that showed one item is patched in place to show another. This keeps scrolling cheap, but it means a recycled node briefly carries the previous item's async-loaded content (e.g. an<img>still showing the oldsrcuntil the new one loads). Give any such element its ownkeytied to the item so the recycled node is swapped for a fresh one instead of reused:renderItem={(data) => ( <ion-card> <img key={data.url} src={data.url} loading="lazy" /> </ion-card> )}
Item shape (IVItem)
interface IVItem<T = unknown> {
item: T; // your payload, passed to renderItem
height?: number; // fixed item height in px
aspect?: number; // alternative to height: columnWidth / aspect
width?: number;
headerHeight?: number;
class?: string;
background?: string;
offset?: number;
// Written by the component (read-only to you):
readonly calculatedWidth?: number;
readonly calculatedHeight?: number;
readonly translateX?: number;
readonly translateY?: number;
readonly index?: number;
}Properties
| Property | Attribute | Description | Type | Default |
| ------------------- | ---------------------- | --------------------------------------------------------------------------- | ------------------------------------- | ----------- |
| approxItemWidth | approx-item-width | Target item width used to derive the column count. | number | 200 |
| buffer | buffer | Extra pixels rendered above/below the viewport to smooth scrolling. | number | 300 |
| endOfScrollOffset | end-of-scroll-offset | Distance from the bottom at which endOfScroller fires. | number | 300 |
| headerHeight | header-height | Extra height added per item (e.g. for a header row). | number | 0 |
| items | -- | The list to virtualize. Must be mutable (see Important contracts). | WriteableIVItem[] | [] |
| matchMedia | match-media | Media query toggling the active padding index (mobile/desktop). | string \| null | null |
| maxColCount | max-col-count | Maximum number of columns. | number | 5 |
| minColCount | min-col-count | Minimum number of columns. | number | 2 |
| padding | padding | Gap in px; a single value or a [mobile, desktop] tuple toggled by matchMedia. | number \| number[] | 0 |
| renderItem | -- | Required. Renders a single item payload to a VNode / DOM node. | (item: unknown) => VNode \| VNode[] | undefined |
| scrollTarget | scroll-target | Scroll container: a CSS selector (via closest()), an element, or 'window'. Omit to auto-detect <ion-content> → scrollable ancestor → page. See Scroll container. | string \| HTMLElement \| null | null |
| sidePadding | side-padding | Horizontal padding on both edges of the scroller. | number | 0 |
| viewPortMaxWidth | view-port-max-width | Caps the width used when computing the column count. | number | 1366 |
Events
| Event | Description | Type |
| --------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------- |
| columnLayoutChanged | Fires when the resolved column geometry changes (resize, items change). Detail carries the real rendered column width. | CustomEvent<ColumnLayoutDetail> |
| endOfScroller | Fires once when scrolling reaches within endOfScrollOffset of the bottom. Resets on every items change. | CustomEvent<boolean> |
ColumnLayoutDetail:
interface ColumnLayoutDetail {
elementWidth: number; // width of a single column in px, after sidePadding
viewPortCount: number; // number of columns currently rendered
innerWidth: number; // viewport width minus 2 × sidePadding
}Methods
clear() => Promise<boolean>
Empties the rendered node pool.
refresh() => Promise<boolean>
Forces a full layout recalculation and re-render.
restoreScrollPosition(scrollTo?: number | null) => Promise<void>
Scrolls back to the last known position, or to scrollTo when provided. Registered automatically via an IntersectionObserver so scroll position survives route changes.
scrollToTop() => Promise<void>
Scrolls to the top and re-renders.
Local development
npm install
npm start # builds in dev mode and serves the demos at src/index.html
npm test # Vitest spec + Playwright browser tests
npm run build # production build (regenerates dist/, loader/, docs)The test-* demo/benchmark components under src/components/test are excluded from production builds and are not published.
License
MIT © Scan0815
