Skip to content
ShineUI
Install

VirtualScroller

Ten thousand rows, no sweat.

<sui-virtual-scroller>DataStable

About

Windowed list for very large datasets: it mounts only the rows in the viewport, plus overscan above and below, inside a spacer the size of the whole list. Registers and full ledgers stay smooth at ten thousand lines and more. Scrolls down, across, or both. Cells have one fixed size.

Usage

<sui-virtual-scroller item-count="12480" item-height="34" height="220" show-index></sui-virtual-scroller> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-virtual-scroller"); el.addEventListener("suiRange", (e) => console.log(e.detail)); </script>
import { SuiVirtualScroller } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { return <SuiVirtualScroller itemCount="12480" itemHeight="34" height="220" showIndex onSuiRange={(e) => console.log(e.detail)} />; }
<script setup> import { SuiVirtualScroller } from "@shineui/vue"; import "@shineui/core/shineui.css"; const onChange = (e) => console.log(e.detail); </script> <template> <SuiVirtualScroller item-count="12480" item-height="34" height="220" show-index @sui-range="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiVirtualScroller } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiVirtualScroller], template: `<sui-virtual-scroller item-count="12480" item-height="34" height="220" show-index (suiRange)="onChange($event)"></sui-virtual-scroller>`, }) export class Example { onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

renderItem is a property called once per row index while that row is in the window. Only the rows in the viewport plus the overscan are mounted; the spacer holds the scrollbar. show-index adds the bar that counts them.

<sui-virtual-scroller item-count="12480" item-height="34" height="220" show-index></sui-virtual-scroller>
// setup(root): properties and events setup(root) { root.querySelector('sui-virtual-scroller').renderItem = journalRows(root.ownerDocument); }

Horizontal

orientation="horizontal" scrolls sideways instead: itemWidth is the cell width and width is the viewport, so the list is one row tall and no measurement is needed. renderItem still takes the index.

<sui-virtual-scroller id="vs-row" orientation="horizontal" item-count="12480" item-width="150" item-height="56" width="640" show-index></sui-virtual-scroller>
// setup(root): properties and events setup(root) { const vs = root.querySelector('#vs-row'); const doc = root.ownerDocument; vs.renderItem = (i) => { const cell = doc.createElement('span'); cell.style.cssText = "display:flex;flex-direction:column;gap:2px;font-feature-settings:'tnum'"; const ref = doc.createElement('span'); ref.style.cssText = 'font-size:11px;color:var(--ink-faint)'; ref.textContent = `JRNL-${10000 + i}`; const amt = doc.createElement('span'); amt.style.fontWeight = '650'; amt.textContent = money(i); cell.append(ref, amt); return cell; }; }

Grid

orientation="grid" windows both axes: itemCount is the row count, columns is the column count, and renderItem is called with the row and the column. Two hundred periods across two thousand accounts is four hundred thousand cells, of which a couple of hundred are in the page.

<sui-virtual-scroller id="vs-grid" orientation="grid" item-count="2000" columns="200" item-height="34" item-width="110" height="240" width="640" show-index></sui-virtual-scroller>
// setup(root): properties and events setup(root) { root.querySelector('#vs-grid').renderItem = (row, col) => money(row * 7 + col); }

Paged from a server

suiRange reports the mounted window, and refresh() redraws it once the rows arrive. Keep renderItem stable: reassigning it also drops every row already drawn.

<sui-virtual-scroller id="vs-lazy" item-count="12480" item-height="34" height="220"></sui-virtual-scroller>
// setup(root): properties and events setup(root) { const vs = root.querySelector('#vs-lazy'); const doc = root.ownerDocument; const loaded = new Map(); let pending; vs.renderItem = (i) => { const row = loaded.get(i); return row ? line(doc, row.ref, row.amount, false) : line(doc, 'Loading', '', true); }; vs.addEventListener('suiRange', (e) => { const { start, end } = e.detail; clearTimeout(pending); pending = setTimeout(() => { for (let i = start; i < end; i++) { if (!loaded.has(i)) loaded.set(i, { ref: `JRNL-${10000 + i}`, amount: money(i) }); } vs.refresh(); }, 450); }); }

Jump to a row

scrollToIndex puts a row at the top of the viewport, which is how a search result is revealed.

<div style="display:grid;gap:10px"> <div><sui-button id="vs-jump" variant="outline" size="sm" label="Go to line 8,000"></sui-button></div> <sui-virtual-scroller id="vs-target" item-count="12480" item-height="34" height="180"></sui-virtual-scroller> </div>
// setup(root): properties and events setup(root) { const vs = root.querySelector('#vs-target'); vs.renderItem = journalRows(root.ownerDocument); root.querySelector('#vs-jump').addEventListener('click', () => vs.scrollToIndex(7999)); }

API

<sui-virtual-scroller>

Properties

PropertyAttributeTypeDefaultDescription
columnscolumnsnumber0Column count for orientation="grid". A grid with no columns draws nothing.
heightheightnumber280Viewport height in px. Ignored by the horizontal orientation, which is one row tall.
itemCountitem-countnumber0Total number of rows. In a grid this is the row count, not the cell count.
itemHeightitem-heightnumber36Fixed row height in px; registers use 34 to 36.
itemWidthitem-widthnumber160Fixed column width in px. Used by the horizontal and grid orientations, ignored by vertical.
orientationorientationSuiVirtualScrollerOrientation"grid" | "horizontal" | "vertical"'vertical'Scroll axis: down the rows, across the columns, or both.
overscanoverscannumber4Extra rows rendered above and below the viewport. A grid overscans both axes.
renderItemproperty only((index: number, column?: number | undefined) => SuiRenderResult) | undefinedRow renderer (index, column) => string | HTMLElement. Strings render as text. Defaults to "Row n". A grid calls it once per cell, with the row index and the column index; every other orientation calls it with the index alone and leaves column undefined. Called once per cell while that cell stays in the window, so a renderer that builds a fresh element is not rebuilt on every scroll frame. Pass a stable function (a method, not a template arrow): the cached cells are dropped whenever this property changes identity, so a fresh arrow on every parent render redraws the whole window every time. When the rows suiRange asked for arrive, call refresh() instead. Property, not attribute.
showIndexshow-indexbooleanfalseShows a bar above the list that counts the rows and the ones rendered in the window, such as "12,480 rows ยท rendering 16 in the window", for a demo or a performance check. Off by default.
widthwidthnumber640Viewport width in px. Used by the horizontal and grid orientations; a vertical list takes the width of its container.

Events

EventDetailReactVueDescription
suiRangeSuiVirtualScrollerRangeDetailonSuiRange@sui-rangeFires when the mounted window changes, and once on the first render; detail is { start, end } with end exclusive, plus colStart and colEnd in a grid. Fetch start..end here to page a list from a server, then call refresh() when the rows arrive: props in, events out, no lazy flag. Does not bubble.

Methods

MethodSignatureDescription
refresh()refresh(index?: number, column?: number) => Promise<void>Re-runs renderItem for the cells in the window, or for one index when given. Call it when the data behind a suiRange request arrives, so the "pending" rows are redrawn without churning renderItem's identity (which would also throw away every cached cell). In a grid, an index with no column drops that whole row.
scrollToIndex()scrollToIndex(index: number, column?: number) => Promise<void>Scrolls so that row index sits at the top of the viewport, and column column at its inline-start edge (the left, or the right under dir="rtl"). A horizontal list reads index as the column, since it has one row.
setFocus()setFocus() => Promise<void>Focuses the scrolling viewport, the list's one tab stop, where the arrow keys and Page Up and Page Down scroll it. el.focus() on the host does the same.

Types

SuiRenderResult type
No members.
SuiVirtualScrollerOrientation typeWhich way the list scrolls. grid scrolls both ways over columns columns of rows.
No members.
SuiVirtualScrollerRangeDetail interfaceThe mounted window. start is the first row index in the DOM and end is one past the last, so rows.slice(start, end) is exactly what is on screen. Includes overscan on both sides. colStart and colEnd are the same window over the columns, and only a grid reports them.
startnumber
endnumber
colStart?number
colEnd?number

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

See also