jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

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.

install — scroll-virtual

$npx jixoai-ui add scroll-virtual

https://ui.jixoai.com/r/scroll-virtual.json

@tanstack/svelte-virtual window + overscan only sibling: scroll-area

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

scroll-virtual-usage.svelte svelte
<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.

PropertyTypeDefaultDescription
the spacer positioning contextone relative div whose block-size (or inline-size) is TanStack’s totalSize — the scroll height of N rows you never renderannounces: the scrollbar tells the truth about all of them
the rows getVirtualItems() × the children snippetabsolutely-positioned wrappers at item.start — only the window plus overscan existsannounces: rows you scrolled past cease to exist; keep no state in them
the measurement the {@attach measureItem(item)} factoryTanStack measureElement on every row wrapper — dynamic heights correct the estimate automaticallyannounces: the retired use: bound the curried call dead (measured in the lane-B spike) — attachments are the only mount that ran
the passthrough virtualOptionsVirtualizerOptions verbatim — scrollMargin, lanes, getItemKey, onChange…; the reserved keys (count/estimateSize/overscan/horizontal/getScrollElement) land LAST and winannounces: TanStack's docs are the docs
the escape hatch bind:thisscrollToIndex / scrollToOffset / measure / getVirtualizer — or skip this component and use @tanstack/svelte-virtual directlyannounces: 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).

vertical (default)

TanStack's default y-axis windowing — only the visible window plus overscan exists in the DOM.

horizontal

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.

ScrollVirtual usage
<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

PropertyTypeDefaultDescription
count * number—Total item count (TanStack count).
estimateSize number | ((index: number) => number)48Estimated row size in px; dynamic measurement corrects it per row.
overscan number—Items rendered beyond the visible window.
horizontal booleanfalseVirtualize 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 / getVirtualizerexportThe imperative TanStack surface.

Universal props

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity 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.

PropertyTypeDefaultDescription
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.
TokenDefaultSource
the spacerposition: relative + totalSizecomponent
data-jx-sv-row / data-indexabsolute wrappers at item.startcomponent
virtualOptionsVirtualizerOptions verbatimcomponent
getVirtualizer()the raw instancecomponent
the composed ScrollArearegion + scrollbar lawstructural
kernel channelsUNREAD by the wrapperdensity
one real query() case
<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.

scroll-virtual-query-demo.svelte svelte
<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

KeyAction
TabFocuses the scrollable region (role=region + tabindex=0 from the composed ScrollArea)
↑ ↓ ← → / Home / End / PgUp / PgDnNative scrollport scrolling; the window re-renders as rows enter and leave

ARIA

AttributeValueDescription
aria-labellabel propAccessible name for the region (default "virtual list")
roleregionFrom the composed ScrollArea wrapper
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h