Playground
A scroll container for a list of any length. The server renders the items as plain HTML, and the page holds only the rows in view plus a buffer around them. The component does the geometry: it makes the scrollbar as long as the whole list, puts the items where they belong, and asks for a new window when you scroll far enough. This is Anders Murphy's approach in hyperlith.
The server owns offset, total and the items. The scroll position stays in the browser. There is no value to send back, so no command contract either.
Examples
A million rows from the server
A star catalog of 1,000,000 lines, which the server computes from each line's index (/demo/data/list). Drag the scrollbar anywhere: the rows arrive a moment later, and the bars stand in for rows that are on their way.
<style>
#stars > div { display: flex; gap: 1.5ch; align-items: center; padding-inline: 0.75rem; font-size: 0.75rem; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; }
#stars b { color: var(--sb-text-1); }
</style>
<sb-virtual-scroll id="stars" label="Star catalog" item-size="17" data-ignore-morph
data-on:sb-window="@get('/demo/data/list?id=stars&offset=' + evt.detail.offset + '&count=' + evt.detail.count)"></sb-virtual-scroll>
- On connect, and whenever the viewport passes half the buffer, the list emits
sb-windowwith{ offset, count }: the rows in view plus the buffer (4,000px of rows by default) above and below. @getasks the server for that window.- The server answers with the host element: the new
offsetandtotal, and the items as its children. Datastar's morph patches it like any other markup.
data-ignore-morph keeps this page's own frames away from the list (see Server side).
Column headings and jumping to an item
The header slot stays at the top while the rows scroll under it, and scrolls sideways with them: these rows are wider than a phone, so the list scrolls both ways. scrollToIndex() jumps to any item and so asks for its window.
<style>
#catalog { block-size: 16rem; }
#catalog > div { display: grid; grid-template-columns: 6.5rem 3.5rem 10rem 6rem 6rem 4rem; gap: 1rem; align-items: center; inline-size: max-content; min-inline-size: 100%; padding-inline: 0.75rem; font-size: 0.8125rem; font-variant-numeric: tabular-nums; }
#catalog > [slot="header"] { block-size: 2rem; background: var(--sb-surface-card); border-block-end: 1px solid var(--sb-border); color: var(--sb-text-muted); font-size: 0.75rem; }
</style>
<div style="display: grid; gap: 12px; inline-size: 100%">
<sb-virtual-scroll id="catalog" label="Star catalog" item-size="28" data-ignore-morph
data-on:sb-window="@get('/demo/data/list?id=catalog&total=100000&header&offset=' + evt.detail.offset + '&count=' + evt.detail.count)">
<div slot="header" aria-hidden="true"><span>Star</span> <span>Class</span> <span>Constellation</span> <span>Brightness</span> <span>Distance</span> <span>Planets</span></div>
</sb-virtual-scroll>
<div style="display: flex; gap: 8px">
<sb-button size="sm" variant="outline" data-on:click="document.getElementById('catalog').scrollToIndex(0)">First</sb-button>
<sb-button size="sm" variant="outline" data-on:click="document.getElementById('catalog').scrollToIndex(49999)">Star 50,000</sb-button>
<sb-button size="sm" variant="outline" data-on:click="document.getElementById('catalog').scrollToIndex(99999, { block: 'end' })">Last</sb-button>
</div>
</div>
The header is part of the host's markup, so the server sends it with every window (&header here). Without it, the morph would remove it.
A grid
columns lays the items out in rows of that many: here a million pixels of a nebula, 48 to a row. With many items per row, a smaller buffer keeps the windows small.
<style>
#nebula { block-size: 16rem; }
#nebula .p1 { background: color-mix(in oklch, var(--sb-brand) 20%, transparent); }
#nebula .p2 { background: color-mix(in oklch, var(--sb-brand) 45%, transparent); }
#nebula .p3 { background: color-mix(in oklch, var(--sb-brand) 75%, transparent); }
#nebula .p4 { background: var(--sb-brand-light); }
#nebula .p5 { background: var(--sb-text-1); }
</style>
<sb-virtual-scroll id="nebula" label="Nebula, one pixel per item" item-size="12" columns="48" buffer="240" data-ignore-morph
data-on:sb-window="@get('/demo/data/list?id=nebula&kind=pixels&columns=48&offset=' + evt.detail.offset + '&count=' + evt.detail.count)"></sb-virtual-scroll>
Server side
The handler behind these examples is Go with the Datastar SDK; any SDK works the same way. PatchElements morphs the element with the id in the markup, and demoListItem renders a star's line or a pixel:
// demoListKeep are the host attributes the page sets. This endpoint serves any
// page, so it sends only offset, total and the items, and the morph keeps these
// as the page has them. data-ignore-morph keeps the page's own frames, which
// know nothing of the window, away from the list.
const demoListKeep = "item-size columns buffer label role style class data-ignore-morph data-on:sb-window"
// demoList answers an sb-virtual-scroll's sb-window with ?count= items from
// ?offset= (at most 5000), patched into the host with the id ?id=. The list is
// the star catalog: its million stars, or the first ?total=. &header adds the
// column headings; &kind=pixels&columns=<n> sends pixels of a nebula n wide.
func (s *Server) demoList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
id := q.Get("id")
if !elementIDRe.MatchString(id) {
http.Error(w, "id must be an element id", http.StatusBadRequest)
return
}
total, _ := strconv.Atoi(q.Get("total"))
if total <= 0 || total > demo.CatalogSize {
total = demo.CatalogSize
}
offset, _ := strconv.Atoi(q.Get("offset"))
count, _ := strconv.Atoi(q.Get("count"))
offset = min(max(offset, 0), total)
count = min(max(count, 0), 5000, total-offset)
cols := 0
if q.Get("kind") == "pixels" {
cols, _ = strconv.Atoi(q.Get("columns"))
cols = max(cols, 1)
}
// The whole host: the morph patches it like any other markup.
var b strings.Builder
fmt.Fprintf(&b, `<sb-virtual-scroll id="%s" offset="%d" total="%d" data-preserve-attr="%s">`, id, offset, total, demoListKeep)
if q.Has("header") {
b.WriteString(`<div slot="header" aria-hidden="true"><span>Star</span> <span>Class</span> <span>Constellation</span> <span>Brightness</span> <span>Distance</span> <span>Planets</span></div>`)
}
for i := offset; i < offset+count; i++ {
// aria-posinset and aria-setsize: the item's place in the whole list.
attrs, content := demoListItem(i, cols)
fmt.Fprintf(&b, `<div role="listitem" aria-posinset="%d" aria-setsize="%d"%s>%s</div>`, i+1, total, attrs, content)
}
b.WriteString(`</sb-virtual-scroll>`)
w.Header().Set("Access-Control-Allow-Origin", "*") // public; used from the playground sandbox
sse := datastar.NewSSE(w, r, datastar.WithCompression(datastar.WithBrotli(datastar.WithBrotliLevel(5)), datastar.WithGzip()))
sse.PatchElements(b.String())
}
- A query that patches the host, like this one: it doesn't know the rest of the host's markup, so
data-preserve-attrtells the morph to keep those attributes. This page re-renders as a whole on every change, from markup that knows nothing of the window, so its lists carrydata-ignore-morph. The endpoint's own patches still apply: they don't carry the attribute. - A page that re-renders as a whole, like hyperlith's: the window is the tab's state.
sb-windowposts a command that storesoffsetandcount, and the page renders the list with its window and everything else. Nothing to preserve or ignore then, and the first render comes with its first window.
Windows
- What the component asks for:
sb-windowcarriesoffset(the first item, a multiple ofcolumns) andcount(enough rows to fill the viewport and the buffer on both sides). It asks once on connect, again when the viewport passes half the buffer on either side, and after a resize that needs more or fewer rows. Never on every scroll event, one request at a time, and never the window it asked for last: the same request would bring the same answer. - What the server sends: the host with
offset(the index of its first child, from 0),totaland the items as its children, in order. It may send fewer items than asked for (a cap): the list then asks for windows that fit the cap, with the viewport in the middle, so the rows in view still come. An empty list istotal="0"and asks for nothing; a host withouttotalasks for its first window again, as on connect. - Items: every item is one row of
item-sizepx (or one cell of a row, withcolumns). Give the items noid: the morph patches them by position. - Placeholders: the rows outside the current window show bars until their window arrives. The list never shows an item the server hasn't sent.
- Length: browsers cap how tall an element can be, Firefox at about 17.9 million px (Chrome and Safari at about 33.5 million). Keep rows ×
item-sizeunder that: a million rows of 17px fit everywhere; for more, usecolumns.
Methods
scrollToIndex(index, { block }) scrolls to the item at index (from 0). block is 'start' (the default: the item at the top), 'end', or 'nearest' (only as far as needed to show it, for keyboard navigation). When the item is outside the current window, the scroll asks for its window.
Styling
Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.
- Size:
block-sizeis20remby default; set it on the element (the list needs a height of its own to scroll). It fills the width it is given. Rows are exactlyitem-sizepx high, andcolumnsshare the width. - Items: they are your markup, styled by your page's CSS (
#stars > div). Rows wider than the list make it scroll sideways. - Fonts: the component draws no text.
- Colours: the placeholder bars are
--sb-surface-hover; the focus ring is--sb-brand-light. The header has no background of its own: give your header element one, or rows show through it. - Parts:
scroller(the scrolling box),header(the sticky header row) andwindow(the grid that holds the items). Your page's::part()rules win over the component's own, without!important. - Motion: nothing animates, and jumps are instant. For smooth ones, set
scroll-behavior: smoothon::part(scroller)inside@media (prefers-reduced-motion: no-preference). - State:
:state(loading)while a window is on its way.
<style>
#styled { block-size: 12rem; border: 1px solid var(--sb-border); border-radius: var(--sb-radius); --sb-surface-hover: var(--sb-brand-subtle); }
#styled > div { padding-inline: 1rem; align-content: center; border-block-end: 1px solid var(--sb-border-subtle); }
#styled:state(loading)::part(scroller) { cursor: progress; }
</style>
<sb-virtual-scroll id="styled" label="Star catalog" item-size="36" data-ignore-morph
data-on:sb-window="@get('/demo/data/list?id=styled&total=10000&offset=' + evt.detail.offset + '&count=' + evt.detail.count)"></sb-virtual-scroll>
Accessibility
- Roles: the scroller is a
list, named bylabel. The items are the server's markup, so the server renders their role:role="listitem", witharia-posinset(the position, from 1) andaria-setsize(the total), so screen readers know where an item sits in the whole list, not only in the window. The component never changes the items: a morph would strip a role it added, and it would override the role of an item that is a link or a button. - Other structures: a
roleon the host hands the semantics to the page, and the scroller drops itslistrole: a feed (role="feed"withrole="article"items), or a grid (role="grid", rows witharia-rowindex). Name the host witharia-labelthen. - Keyboard: when no item has anything to focus, the scroller takes the focus itself, so the arrow keys, Page Up and Down, Home and End scroll it. Otherwise Tab goes to the items. The morph patches the items by position, so after a window arrives a focused element can hold another item: a page that moves the focus from item to item scrolls with
scrollToIndex(i, { block: 'nearest' })and focuses the item once it is there.
Installation
Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later.
<script type="importmap">
{ "imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>
<sb-virtual-scroll id="stars" label="Stars" item-size="32"
data-on:sb-window="@get('/stars?offset=' + evt.detail.offset + '&count=' + evt.detail.count)"></sb-virtual-scroll>No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.
Load just this component, pinned to this version: the minified module with its integrity hash, so the browser refuses it if a single byte changes.
<script type="importmap">
{
"imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" },
"integrity": {
"https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js": "sha384-vUxZojLrF1Ar3de5h7VINqhJXBgjyZS4U49pHvGa87kum6j5Xn6JRziuARNw5ELG",
"https://starbase.zweiundeins.gmbh/c/virtual-scroll@37b12bfee9a2/virtual-scroll.min.js": "sha384-bFcx0EoC34mIg1yPxLBlfm2whzOxwekW2m48EbLys9d3pZ7Jh6p+sVeKPVA/S0jh"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/virtual-scroll@37b12bfee9a2/virtual-scroll.min.js" integrity="sha384-bFcx0EoC34mIg1yPxLBlfm2whzOxwekW2m48EbLys9d3pZ7Jh6p+sVeKPVA/S0jh"></script>
<sb-virtual-scroll id="stars" label="Stars" item-size="32"
data-on:sb-window="@get('/stars?offset=' + evt.detail.offset + '&count=' + evt.detail.count)"></sb-virtual-scroll>In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.
<script type="importmap">
{
"imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" },
"integrity": {
"https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js": "sha384-vUxZojLrF1Ar3de5h7VINqhJXBgjyZS4U49pHvGa87kum6j5Xn6JRziuARNw5ELG",
"https://starbase.zweiundeins.gmbh/c/virtual-scroll@37b12bfee9a2/virtual-scroll.min.js": "sha384-bFcx0EoC34mIg1yPxLBlfm2whzOxwekW2m48EbLys9d3pZ7Jh6p+sVeKPVA/S0jh"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@5fa59f8dc44a/autoloader.js" integrity="sha384-uIkM/3WJfrJGqPA5t7gqz6wwMDPADCPt9hxjY8Iax8QzMQbEnlfRNoTA5xCgZcTU"></script>
<sb-virtual-scroll id="stars" label="Stars" item-size="32"
data-on:sb-window="@get('/stars?offset=' + evt.detail.offset + '&count=' + evt.detail.count)"></sb-virtual-scroll>Components import only datastar and their own files, so copying the folder is enough: save these files, keeping each component's folder, and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).
<script type="importmap">
{ "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/virtual-scroll/virtual-scroll.min.js"></script>
<sb-virtual-scroll id="stars" label="Stars" item-size="32"
data-on:sb-window="@get('/stars?offset=' + evt.detail.offset + '&count=' + evt.detail.count)"></sb-virtual-scroll>Size
Each file compressed on its own, the way it is served (gzip -9, brotli -11). The autoloader loads the minified files (esbuild), so that last column is what a page downloads; the readable source is always there too. Datastar and Rocket are shared by every component and not counted.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
virtual-scroll.js | 9.0 kB | 3.7 kB | 3.2 kB | 1.7 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
total | number | 0 | Server data: how many items the whole list has. |
offset | number | 0 | Server data: the index of the first child in the whole list (from 0, a multiple of columns). |
item-size | number | 32 | Row height in px. Every item is exactly one row high. |
columns | number | 1 | Items per row: more than 1 lays them out as a grid. |
buffer | number | 4000 | How far a window reaches past the viewport, above and below, in px. |
label | string | "" | Accessible name of the list. |
Slots
| Name | Description |
|---|---|
default | The items of the current window, one element each, in order. The server renders them. |
header | Stays at the top while the items scroll under it, and scrolls sideways with them (column headings). |
Events
| Name | Description |
|---|---|
sb-window | Asks for a window of items. detail: { offset, count }. Answer by re-rendering the host with those items as children and the new offset and total. |