VariableVirtualList
Keep large collections responsive by mounting a small scrolling window of measured variable-height rows.
VariableVirtualListInteractive playground
Browse a measured activity feed with short notes, wrapping paragraphs and expandable detail. Watch actual mounted-row counts and row-height ranges as you scroll. Try a narrow feed, a different initial estimate, or a larger dataset.
<RegorApp id="variable-virtual-list-demo" src="./playground.ts"/> import {
defineBadgeComponents,
defineButtonComponents,
defineFlexComponents,
defineFormComponents,
defineFormInputField,
defineFormSelectField,
defineGridComponents,
defineIconComponents,
definePanelComponents,
defineVirtualListComponents,
type FormSelectOption,
} from '@purestack/ts-components'
import { lucide_chevron_down } from '@purestack/ts-svg-icons'
import {
batch,
type ComputedRef,
computed,
createApp,
defineComponent,
html,
observe,
onMounted,
onUnmounted,
type Ref,
type RefOrValue,
ref,
type SRef,
sref,
} from 'regor'
export interface ActivityRecord {
id: number
title: string
summary: string
detail: string
expanded: Ref<boolean>
}
export interface ActivityRow {
item: RefOrValue<ActivityRecord>
index: RefOrValue<number>
}
const activityRowTemplate = html`<article
class="p-3 bb-1 b-subtle"
role="listitem"
:aria-posinset="index + 1"
:data-row="index"
>
<Flex justify="between" align="center" wrap="true">
<strong>{{ item.title }}</strong>
<Btn
variant="outline"
size="sm"
:aria-expanded="item.expanded"
:aria-controls="'activity-detail-' + item.id"
@click="item.expanded = !item.expanded"
>{{ item.expanded ? 'Less detail' : 'More detail' }}</Btn
>
</Flex>
<p class="mb-0">{{ item.summary }}</p>
<div r-if="item.expanded" :id="'activity-detail-' + item.id" class="mt-3">
<Badge tone="info" variant="surface">Implementation notes</Badge>
<p class="mb-0">{{ item.detail }}</p>
</div>
</article>`
const activityRow = defineComponent<ActivityRow>(activityRowTemplate, {
props: ['item', 'index'],
})
export interface VariableListPlayground {
records: SRef<ActivityRecord[]>
filtered: ComputedRef<ActivityRecord[]>
query: Ref<string>
count: Ref<string>
height: Ref<string>
estimate: Ref<string>
overscan: Ref<string>
narrow: Ref<boolean>
active: Ref<boolean>
mountedRows: Ref<number>
rowRange: Ref<string>
measuredRange: Ref<string>
scrollExtent: Ref<number>
counts: FormSelectOption[]
heights: FormSelectOption[]
estimates: FormSelectOption[]
buffers: FormSelectOption[]
restart: () => void
load: () => void
clear: () => void
reset: () => void
first: () => void
next: () => void
previous: () => void
}
const variableListPlaygroundTemplate = html`<Flex direction="column">
<Grid columns="1" columnsMd="2">
<FormSelectField
id="activity-count"
label="Dataset size"
:model="count"
:options="counts"
@change="load"/>
<FormInputField
id="activity-search"
label="Search activity"
placeholder="Try Change 42"
:model="query"/>
</Grid>
<Panel variant="surfaceAlt" bodyClass="p-3 min-w-0">
<Flex justify="between" align="center" wrap="true">
<p class="text-eyebrow m-0">LIVE PREVIEW · ACTIVITY FEED</p>
<Badge tone="accent" variant="surface"
>{{ filtered.length }} matching records</Badge
>
</Flex>
<Grid columns="2" columnsMd="4" class="my-3">
<div>
<p class="m-0 text-muted">Mounted rows</p>
<strong id="activity-mounted">{{ mountedRows }}</strong>
</div>
<div>
<p class="m-0 text-muted">Positions</p>
<strong>{{ rowRange }}</strong>
</div>
<div>
<p class="m-0 text-muted">Row heights</p>
<strong id="activity-measured">{{ measuredRange }}</strong>
</div>
<div>
<p class="m-0 text-muted">Scroll height</p>
<strong>{{ scrollExtent }} px</strong>
</div>
</Grid>
<div
id="activity-host"
class="mx-auto"
:style="{ maxWidth: narrow ? '28rem' : '100%' }"
>
<VariableVirtualList
r-if="active"
id="activity-viewport"
:items="filtered"
:height="height"
:estimateHeight="estimate"
:overscan="overscan"
rowComponent="ActivityRow"
tabindex="0"
role="list"
aria-label="Expandable change activity"
class="b-1 b-subtle rounded-md"/>
</div>
<FormStatus r-if="!filtered.length" role="status"
>{{ records.length ? 'No matching activity. Clear the search to see the feed.' : 'The feed is empty. Reload the dataset to continue.'
}}
</FormStatus>
<p class="mb-0 text-muted">
Expand a row or narrow the feed. The row height is measured from its
content; it is not fixed to the initial estimate.
</p>
</Panel>
<Grid columns="1" columnsMd="3">
<FormSelectField
id="activity-height"
label="Viewport height"
:model="height"
:options="heights"/>
<FormSelectField
id="activity-estimate"
label="Initial height estimate"
:model="estimate"
:options="estimates"
@change="restart"/>
<FormSelectField
id="activity-overscan"
label="Overscan"
:model="overscan"
:options="buffers"/>
</Grid>
<FormCheck
id="activity-narrow"
label="Narrow feed to 28rem"
:checked="narrow"
@change="restart"/>
<Flex wrap="true">
<Btn variant="outline" :disabled="!filtered.length" @click="first"
>Back to top</Btn
>
<Btn variant="outline" :disabled="!filtered.length" @click="previous"
>Previous screen</Btn
>
<Btn
tone="accent"
variant="surface"
:disabled="!filtered.length"
@click="next"
>Next screen</Btn
>
</Flex>
<p class="m-0 text-muted">
The counters inspect mounted rows. Scroll height includes estimates for
unseen rows and can change as you browse. Search, dataset, width and
estimate changes restart measurement from the top; expanded state stays on
each record.
</p>
<Flex wrap="true">
<Btn variant="outline" @click="restart">Restart measurement</Btn>
<Btn variant="outline" @click="load">Reload dataset</Btn>
<Btn variant="outline" @click="clear">Empty feed</Btn>
<Btn tone="accent" variant="surface" @click="reset">Reset playground</Btn>
</Flex>
</Flex>`
function createActivities(count: number): ActivityRecord[] {
const summaries = [
'Updated the component documentation.',
'Improved keyboard navigation and focus visibility across the release workflow. The review includes both desktop and narrow layouts.',
'Added examples for empty results, long labels and loading transitions. Each example uses the shared theme, typed component props and real application state so it can be adapted to a production screen.',
]
return Array.from({ length: count }, (_, index) => ({
id: index + 1,
title: `Change ${index + 1}`,
summary: summaries[index % summaries.length],
detail:
'The implementation keeps interaction state on the record. Expand this entry, scroll until it leaves the mounted window, and return to it: the notes remain open. Content can grow without assigning a new row height. ResizeObserver measures the rendered result and updates the virtual offsets.',
expanded: ref(false),
}))
}
function createVariableListPlayground(): VariableListPlayground {
const records = sref(createActivities(1000))
const query = ref(''),
count = ref('1000'),
height = ref('400'),
estimate = ref('140'),
overscan = ref('4')
const narrow = ref(false),
active = ref(true)
const mountedRows = ref(0),
rowRange = ref('-'),
measuredRange = ref('-'),
scrollExtent = ref(0)
const filtered = computed(() =>
records().filter((item) =>
item.title.toLowerCase().includes(query().trim().toLowerCase()),
),
)
let disposed = false
const restart = () => {
active(false)
queueMicrotask(() => {
if (!disposed) active(true)
})
}
const stop = observe(filtered, restart)
const viewport = () =>
document.querySelector<HTMLElement>('#activity-viewport')
const scroll = (direction: number) => {
const element = viewport()
if (!element) return
element.scrollTop =
direction === 0 ? 0 : element.scrollTop + direction * element.clientHeight
element.dispatchEvent(new Event('scroll'))
}
const measure = () => {
const element = viewport()
const rows = Array.from(
element?.querySelectorAll<HTMLElement>('[data-row]') ?? [],
)
const sizes = rows.map((row) =>
Math.round(row.getBoundingClientRect().height),
)
mountedRows(rows.length)
rowRange(
rows.length
? `${Number(rows[0].dataset.row) + 1}–${Number(rows.at(-1)?.dataset.row) + 1}`
: '-',
)
measuredRange(
sizes.length ? `${Math.min(...sizes)}–${Math.max(...sizes)} px` : '-',
)
scrollExtent(element?.scrollHeight ?? 0)
}
let observer: MutationObserver | undefined
let resize: ResizeObserver | undefined
onMounted(() => {
const host = document.querySelector('#activity-host')
if (!host) return
observer = new MutationObserver(measure)
observer.observe(host, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style', 'data-row'],
})
resize = new ResizeObserver(measure)
resize.observe(host)
measure()
})
onUnmounted(() => {
disposed = true
stop()
observer?.disconnect()
resize?.disconnect()
})
return {
records,
filtered,
query,
count,
height,
estimate,
overscan,
narrow,
active,
mountedRows,
rowRange,
measuredRange,
scrollExtent,
counts: [100, 1000, 10000, 50000].map((n) => ({
label: `${n.toLocaleString('en-US')} records`,
value: String(n),
})),
heights: [280, 400, 520].map((n) => ({
label: `${n} px`,
value: String(n),
})),
estimates: [56, 140, 240].map((n) => ({
label: `${n} px`,
value: String(n),
})),
buffers: [1, 4, 12].map((n) => ({ label: `${n} rows`, value: String(n) })),
restart,
load: () => records(createActivities(Number(count()))),
clear: () => records([]),
first: () => scroll(0),
previous: () => scroll(-1),
next: () => scroll(1),
reset: () =>
batch(() => {
query('')
count('1000')
height('400')
estimate('140')
overscan('4')
narrow(false)
records(createActivities(1000))
}),
}
}
const variableListPlayground = defineComponent<VariableListPlayground>(
variableListPlaygroundTemplate,
{ context: createVariableListPlayground },
)
const icons: Record<string, string> = {
'lucide:chevron-down': lucide_chevron_down,
}
createApp(
{
components: {
VariableListPlayground: variableListPlayground,
ActivityRow: activityRow,
...defineBadgeComponents(),
...defineButtonComponents(),
...defineFlexComponents(),
...defineFormComponents(),
...defineFormInputField(),
...defineFormSelectField(),
...defineGridComponents(),
...defineIconComponents((name) => icons[name] ?? ''),
...definePanelComponents(),
...defineVirtualListComponents(),
},
},
{
selector: 'app#variable-virtual-list-demo',
template: html`<VariableListPlayground/>`,
},
) A minimal measured list
Let the row content determine its height. This complete example renders 200 notes with alternating paragraph lengths; no row height is assigned.
<RegorApp id="variable-list-basic-demo" src="./basic-list.ts"/> import { defineVirtualListComponents } from '@purestack/ts-components'
import { createApp, defineComponent, html, type RefOrValue } from 'regor'
export interface Note {
title: string
body: string
}
export interface NoteRow {
item: RefOrValue<Note>
index: RefOrValue<number>
}
const noteRowTemplate = html`<article class="p-3 bb-1 b-subtle" role="listitem" :aria-posinset="index + 1" aria-setsize="200">
<strong>{{ item.title }}</strong>
<p class="mb-0">{{ item.body }}</p>
</article>`
const noteRow = defineComponent<NoteRow>(noteRowTemplate, {
props: ['item', 'index'],
})
export interface NotesExample {
notes: Note[]
}
const notesTemplate = html`<VariableVirtualList
:items="notes" height="300" estimateHeight="100" overscan="3"
rowComponent="NoteRow" role="list" tabindex="0" aria-label="Release notes"
class="b-1 b-subtle rounded-md"
/>`
const notesExample = defineComponent<NotesExample>(notesTemplate, {
context: () => ({
notes: Array.from({ length: 200 }, (_, index) => ({
title: `Note ${index + 1}`,
body:
index % 2
? 'A focused documentation update.'
: 'This update adds clearer examples for keyboard navigation, empty results and responsive layouts. The paragraph wraps naturally, and the virtual list measures the resulting row height.',
})),
}),
})
createApp(
{
components: {
NotesExample: notesExample,
NoteRow: noteRow,
...defineVirtualListComponents(),
},
},
{
selector: 'app#variable-list-basic-demo',
template: html`<NotesExample />`,
},
) How measurement works
01 · ESTIMATE
Start with a guess
estimateHeight supplies the initial size of unseen rows. It is a starting point for offsets, not a constraint on the content.
02 · MEASURE
Read the real height
ResizeObserver tracks mounted rows. Wrapping text and expanded content update their measured sizes.
03 · REFINE
Update the offsets
Measured rows keep their sizes. The running average of measurements supplies the estimate for rows that have not been measured.
The component finds the visible range from cumulative row offsets, then adds an overscan buffer. During scrolling, measurement changes are collected and applied after a short idle period (currently 120 ms). The estimated total and scrollbar thumb can change as new content is measured; this is not an exact, premeasured scroll map.
Changing estimateHeight after measurements exist does not replace the running average or clear the cache. The playground restarts the component when you change the estimate so you can try a fresh initial value.
Expansion, filtering and measurement lifetime
Expanded state belongs to each record. Scroll an expanded row out of the mounted range and return to it: the same ref restores its content. The row component itself does not own durable state.
Measurements are currently cached by array index, not by a record key. Filtering, sorting or replacing the dataset can associate old heights with different records. The playground deliberately unmounts and remounts the virtual component when the sequence changes. This clears measurements and returns to the top while retaining the records and their expansion refs.
Width changes remeasure mounted content, but cached heights for off-screen rows remain until those rows are rendered again. The width control restarts measurement to avoid reusing old widths' measurements. Ordinary browser resizing can still refine those heights progressively as you browse.
Restart measurement is an example action implemented with r-if; it is not a component method. Next screen and Previous screen move by the actual viewport height. An exact jump to an arbitrary row would require known cumulative heights; multiplying an index by estimateHeight cannot guarantee that result.
Behavior and accessibility
- Keyboard scrolling: the examples add tabindex="0" to the viewport. Arrow keys and Page Up / Page Down use native browser scrolling. These components do not implement spreadsheet navigation or roving focus.
- List semantics: the viewport has role="list", an accessible name and listitem rows. The minimal example also exposes the known total with aria-setsize and each position with aria-posinset.
- State and focus: keep durable state on records or in an external store, keyed by stable identity. A row outside the mounted window is removed, including any focused control inside it.
- Empty results: compose a message outside the virtual content. An empty array leaves the configured viewport with no rows.
- Complete data access: browser find, printing and assistive navigation only see mounted rows. Provide application search or a full-data view or export where complete access is needed.
- Memory and loading: virtualization reduces mounted DOM, not the source array or filtering cost. Data fetching, pagination, selection and sorting remain application responsibilities.
- Scroll stability: total height and scrollbar position can shift as estimates are replaced. These examples do not promise exact deep-link offsets or scroll anchoring during large size changes.
API reference
VariableVirtualList contract
Props below are the public template API. RefOrValue accepts a literal or a reactive ref; bind refs with a colon-prefixed attribute. Native attributes and events can be passed through the component root.
Data
items
RefOrValue<unknown[]> - Default
[]
The complete source array. Only a window is mounted; all records remain in memory. Bind a ref or computed array to replace, filter or sort it. Non-array values resolve to an empty array. Measurements are cached by index; remount for a fresh cache when record order or identity changes.
Viewport and window
height
RefOrValue<number | string> - Default
560
Viewport height in pixels. Positive numbers and numeric strings are accepted; invalid, non-finite and non-positive values fall back to 560. Use pixel values, not CSS units such as rem or %. This controls the viewport, not a row.
estimateHeight
RefOrValue<number | string> - Default
56
Initial pixel estimate before rows are measured. Invalid or non-positive values fall back to 56. It does not constrain rendered height. Once measurements exist, their running average estimates unseen rows; changing this prop does not clear cached measurements.
overscan
RefOrValue<number | string> - Default
6
Extra row buffer before and after the estimated visible range, clipped at array boundaries. More overscan mounts and measures more content. Use a positive integer; positive fractions truncate, while zero, negative and invalid values fall back to 6.
Row rendering
rowComponent
RefOrValue<string> - Default
div
Registered component name for each row. It receives item and the absolute zero-based index in the current items array. Allow its height to follow the content; avoid margins outside the measured wrapper. The default div does not format item data.
Row contract
import type { RefOrValue } from 'regor'
interface ExampleRow {
item: RefOrValue<YourRecord>
index: RefOrValue<number>
} Replace YourRecord with your application type and declare item and index in the component's props list. Index is a position in the current array, not a stable identity. The examples define exported interfaces, named templates and explicit registrations in their own TypeScript files.
Composition and public API
No slots. Register defineVirtualListComponents and your named row component. Controls, empty messages and durable state belong to your application. Native attributes and events are inherited by the scrollable root div.
Only the registered props above are public template inputs. itemHeight is not registered for this component. Internal measurement refs and methods are implementation details; there is no public cache-reset or scrollToIndex API.