VariableVirtualList

Keep large collections responsive by mounting a small scrolling window of measured variable-height rows.

VariableVirtualList

View source · API reference

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