registry:ui · Data Display
scroll-virtual — the windowed list
Virtual scrolling for the scroll-area family — STRONGLY associated with @tanstack/svelte-virtual (the official Svelte 5 adapter over @tanstack/virtual-core) with deliberately THIN coupling: windowing semantics, dynamic measurement, scrollToIndex/scrollToOffset, lanes, getItemKey are all TanStack's; read TanStack Virtual's docs directly. This component adds only the DOM wiring: composes scroll-area (label passthrough — the variant prop retired with the 2026-09-15 family rework: the composed area is always hand-drawn; the platform path is native-scroll-area), owns the positioning layer (totalSize spacer + absolutely positioned rows), and calls item.measureElement on its own row wrappers so dynamic heights work out of the box. Opt-in by design — scroll-area alone has ZERO TanStack dependency.
$npx jixoai-ui add scroll-virtual
https://ui.jixoai.com/r/scroll-virtual.json
overview
Overview
Strong TanStack association, thin coupling: the windowing semantics are TanStack's, the DOM wiring is ours, the paint is yours.
scroll-virtual is the scroll-area family's windowed-list sibling: a thin DOM-wiring
layer over @tanstack/svelte-virtual (the official Svelte 5 adapter over
virtual-core). The component adds exactly three things to TanStack: the ScrollArea
composition (label and class passthrough — the region carries the WAI
scrollable-region pattern and the hand-drawn scrollbar law), the positioning layer
(a spacer sized to TanStack's totalSize plus absolutely-positioned rows
with automatic measureElement — dynamic heights work with one less step
than raw TanStack), and the escape hatch
(getVirtualizer() hands back the raw instance). On-demand install: the
scroll-area family has zero TanStack dependencies; adding this component is what
installs it.
The delivery shape is the fleet's ENGINE-WRAPPER dialect: scroll-virtual owns no DOM root of its own — the composed ScrollArea renders the region, stamps the §11 carriers, supplies downward and anchors query() at its root. The resolved lanes FORWARD: density, size, shape, radius, color, theme, elevation and motion all land on the composed region (the hand-drawn capsule follows the scroll-area family's own stories), and the windowed rows inherit what the region's scope re-bases. The spacer and the absolutely-positioned rows are TanStack engine internals, documented outside the supply set.
The laws worth knowing before you compose: virtualization runs along one axis
(horizontal switches x for y); row spacing is expressed as inline row
margins (TanStack has no gap concept); the ring padding of the ScrollArea's pad
must not be combined with a horizontal virtual list (the inline start offsets the scrollMargin); and the reserved option keys land LAST — the
component's own wiring always wins over the passthrough object. Per-axis below;
the shared grammar lives on the universal props page.
scroll-virtual
TanStack Virtual through a thin DOM-wiring layer: this demo renders up to 100,000 rows — only the visible window plus overscan exists in the DOM. Row heights measure automatically (the component calls measureElement on its own wrappers).
<script lang="ts">
import ScrollVirtual from '@ui/scroll-virtual.svelte';
</script>
<!-- strong TanStack association, thin coupling: read TanStack Virtual's
docs for the semantics; virtualOptions speaks VirtualizerOptions -->
<ScrollVirtual count={rows.length} estimateSize={44} overscan={6} bind:this={list}>
{#snippet children(item)}
<div class="row" data-index={item.index}>{rows[item.index].name}</div>
{/snippet}
</ScrollVirtual>
<!-- imperative surface: TanStack passthroughs -->
<button onclick={() => list.scrollToIndex(999, { align: 'center' })}>jump</button>law
The thin-coupling contract
Everything windowing-related is TanStack's; the wrapper owns the positioning layer, the reserved-key precedence, and the escape hatch — never semantics.
| Property | Type | Default | Description |
|---|---|---|---|
| the spacer | positioning context | one relative div whose block-size (or inline-size) is TanStack’s totalSize — the scroll height of N rows you never render | announces: the scrollbar tells the truth about all of them |
| the rows | getVirtualItems() × the children snippet | absolutely-positioned wrappers at item.start — only the window plus overscan exists | announces: rows you scrolled past cease to exist; keep no state in them |
| the measurement | the {@attach measureItem(item)} factory | TanStack measureElement on every row wrapper — dynamic heights correct the estimate automatically | announces: the retired use: bound the curried call dead (measured in the lane-B spike) — attachments are the only mount that ran |
| the passthrough | virtualOptions | VirtualizerOptions verbatim — scrollMargin, lanes, getItemKey, onChange…; the reserved keys (count/estimateSize/overscan/horizontal/getScrollElement) land LAST and win | announces: TanStack's docs are the docs |
| the escape hatch | bind:this | scrollToIndex / scrollToOffset / measure / getVirtualizer — or skip this component and use @tanstack/svelte-virtual directly | announces: the wrapper adds wiring, never semantics |
types
Types
One windowed list, two scroll axes; the composed ScrollArea is always hand-drawn (the variant prop retired, 2026-09-15).
TanStack's default y-axis windowing — only the visible window plus overscan exists in the DOM.
horizontal=true virtualizes along x — the same options, the same thin DOM wiring.
usage
Usage
Strong TanStack association, thin coupling: the props speak VirtualizerOptions; rows render through the children snippet.
<script lang="ts">
import ScrollVirtual from '@ui/scroll-virtual.svelte';
</script>
<!-- strong TanStack association, thin coupling: read TanStack Virtual's
docs for the semantics; virtualOptions speaks VirtualizerOptions -->
<ScrollVirtual count={rows.length} estimateSize={44} overscan={6} bind:this={list}>
{#snippet children(item)}
<div class="row" data-index={item.index}>{rows[item.index].name}</div>
{/snippet}
</ScrollVirtual>
<!-- imperative surface: TanStack passthroughs -->
<button onclick={() => list.scrollToIndex(999, { align: 'center' })}>jump</button>api
API
Props from the ScrollVirtual Props interface; bind:this exposes the TanStack passthroughs.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| count * | number | — | Total item count (TanStack count). |
| estimateSize | number | ((index: number) => number) | 48 | Estimated row size in px; dynamic measurement corrects it per row. |
| overscan | number | — | Items rendered beyond the visible window. |
| horizontal | boolean | false | Virtualize along x instead of y. |
| virtualOptions | Partial<VirtualizerOptions> | {} | TanStack passthrough — scrollMargin / lanes / getItemKey / initialOffset / onChange / rangeExtractor; reserved keys are overridden. |
| label | string | 'virtual list' | a11y name for the scrollable region. |
| class | string | '' | Class passthrough. |
| onscroll | (event: ViewportScrollEvent) => void | — | The composed ScrollArea viewport scroll event. |
| children * | Snippet<[VirtualItem]> | — | Rendered per virtual item — receives TanStack VirtualItem (index/start/size/key/lane). |
| bind:this | scrollToIndex / scrollToOffset / measure / getVirtualizer | export | The imperative TanStack surface. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
axes
The eight axes on scroll-virtual
The ENGINE-WRAPPER dialect, all no-own (the first-time W3-D3 contract): every resolved lane FORWARDS to the composed ScrollArea region — the region stamps the carriers, supplies downward and anchors query() at its root — and the windowed rows inherit what the region's scope re-bases. The spacer and the absolutely-positioned rows are TanStack engine internals, documented outside the supply set.
| Property | Type | Default | Description |
|---|---|---|---|
| density | 'small' | 'medium' | 'large' | 'auto' | number (+ the five legacy spellings) | 'auto' | FORWARDED — the resolved lane lands on the composed ScrollArea, which stamps the §4 legacy rung on the region root (measured: density="small" → data-density="sm" — today's CSS keys on the rungs), and the hand-drawn scrollbar and chrome follow the scroll-area family's own density story; the windowed rows inherit whatever the region's scope re-bases. The wrapper owns no geometry of its own. Number unit: coefficient. |
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | FORWARDED, INHERITED BY THE ROWS — the §11 stamp lands on the region root (measured on the query() demo below) and the windowed rows inherit it through plain cascade: row paint is the children snippet's, so your rows scale unless they pin their own voice. Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | FORWARDED — resolves on the composed ScrollArea (whose own shape story applies); the wrapper carries no corner. Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | FORWARDED AS THUMB CHROME, NUMBER LANE ONLY — the seam passes typeof d.radius === 'number' ? d.radius : undefined, so named steps and 'auto' are dropped at the forward seam; a landed number is the composed ScrollArea's W3-D2 chrome param (--jx-scroll-thumb-radius — the scrollbar thumb's corner), not a §3 corner stamp (source receipt: scroll-area.svelte, "NOT the radius axis"). Number unit: px. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | FORWARDED — resolves on the composed ScrollArea; the wrapper reads no hue. Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | FORWARDED — the resolved lane lands on the composed ScrollArea, whose hand-drawn capsule follows the scroll-area family's own theme story (the platform path remains native-scroll-area). The wrapper stamps no class of its own. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | FORWARDED — resolves on the composed ScrollArea (its elevation="number" lane is wired); the spacer and rows are flush by construction. Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | FORWARDED — resolves on the composed ScrollArea; zero readers in the wrapper (grep receipt: no --jx-motion-effective anywhere in ui/scroll-virtual/). The window re-render is data flow, not choreography. Number unit: coefficient. |
| Token | Default | Source |
|---|---|---|
| the spacer | position: relative + totalSize | component |
| data-jx-sv-row / data-index | absolute wrappers at item.start | component |
| virtualOptions | VirtualizerOptions verbatim | component |
| getVirtualizer() | the raw instance | component |
| the composed ScrollArea | region + scrollbar law | structural |
| kernel channels | UNREAD by the wrapper | density |
<script lang="ts">
import ScrollVirtual from '@ui/scroll-virtual.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (14px) applies; at 48rem+ the md case (18px)
wins — the lane forwards to the region and the rows inherit it -->
<ScrollVirtual count={rows.length} estimateSize={44} size={query({ md: 18 }, 14)}>…</ScrollVirtual>scroll-virtual · forwarding stamps + query()
density="small" + theme="dark" + radius={8} — the evidence lands on the composed region (the wrapper owns no root): data-density="sm" (the §4 legacy rung), the dark class, and the thumb-corner chrome --jx-scroll-thumb-radius: 8px. The spacer and rows carry none of it.
Media keys are min-width: below 48rem the base (14px) applies — the region stamps 14px and the rows inherit; at 48rem and wider the md case wins — 18px. The number lane goes bare (results infer). Resize across 48rem.
<script lang="ts">
import ScrollVirtual from '@ui/scroll-virtual.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- below 48rem the base (14px) applies; at 48rem+ the md case (18px)
wins — the lane forwards to the region and the rows inherit it -->
<ScrollVirtual count={rows.length} estimateSize={44} size={query({ md: 18 }, 14)}>…</ScrollVirtual>a11y
Accessibility
The composed ScrollArea carries the WAI scrollable-region pattern; rows are your content's semantics.
Keyboard
| Key | Action |
|---|---|
| Tab | Focuses the scrollable region (role=region + tabindex=0 from the composed ScrollArea) |
| ↑ ↓ ← → / Home / End / PgUp / PgDn | Native scrollport scrolling; the window re-renders as rows enter and leave |
ARIA
| Attribute | Value | Description |
|---|---|---|
| aria-label | label prop | Accessible name for the region (default "virtual list") |
| role | region | From the composed ScrollArea wrapper |