npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

Built With Stencil

@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 startbench) 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 matchMedia query.
  • endOfScroller event for infinite-scroll / paging.
  • columnLayoutChanged event 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:

  1. the scrollTarget prop — a CSS selector (resolved with closest()), an element, or the string 'window';
  2. an ancestor <ion-content> — its inner scroll element is obtained via getScrollElement() and it is scrolled with scrollToPoint() (full Ionic support, unchanged);
  3. the nearest scrollable ancestor (an element with overflow-y: auto | scroll);
  4. 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>

scrollTarget is 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-scroller

Using 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> a key tied to its src, 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 .scrollTarget property 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 / sidePadding and the per-item width hint) and set inline. Prefer those props to size the columns; use CSS for the content inside each item.

Important contracts

  • items must 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]).

  • renderItem is required. It is typed optional but the component cannot render without it. It receives the item payload (the value of IVItem.item) and must return a Stencil VNode (or array of VNodes) — created via JSX or h() from @stencil/core. A raw DOM node will not render.

  • Item height comes from item.height (fixed pixels) or, when item.aspect is 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 old src until the new one loads). Give any such element its own key tied 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