VirtualList

Render items near the viewport with dynamic sizing and programmatic scrolling.

  • 1Item 00001Tag
  • 2Item 00002
  • 3Item 00003
  • 4Item 00004Tag
  • 5Item 00005
  • 6Item 00006
  • 7Item 00007Tag
  • 8Item 00008
  • 9Item 00009
  • 10Item 00010Tag
<script setup lang="ts">
  import { VirtualList, Tag, Inline, Text } from '@hina-ui/vue'

  const items = Array.from({ length: 10000 }, (_, id) => ({
    id,
    label: `Item ${String(id + 1).padStart(5, '0')}`,
  }))
</script>

<template>
  <VirtualList
    :items="items"
    :get-key="item => item.id"
    :height="320"
    :estimate-size="64"
    label="Items"
    class="border-line bg-surface rounded-lg border"
  >
    <template #default="{ item, index }">
      <Inline :wrap="false" class="border-line h-16 border-b px-4">
        <Text
          as="span"
          size="xs"
          tone="muted"
          class="bg-inset flex size-9 shrink-0 items-center justify-center rounded-md tabular-nums"
        >
          {{ index + 1 }}
        </Text>
        <Text as="span" size="sm" truncate class="min-w-0 flex-1">{{ item.label }}</Text>
        <Tag v-if="index % 3 === 0" size="sm">Tag</Tag>
      </Inline>
    </template>
  </VirtualList>
</template>
vue

Usage

import { VirtualList, type VirtualListExpose } from '@hina-ui/vue'
ts

Supply items and a getKey function returning a unique, stable string or number. The default slot receives { item, index } and preserves the full item type. Avoid array positions as keys when data can change or move.

The component includes ScrollArea and defaults to a height of 320px. height accepts pixels or a CSS length; 100% requires a parent with a definite height. Item content and appearance are customizable. There is no default border, selection state, or click behavior. The first example contains ten thousand items and uses Tag for trailing content.

The examples use Text for typography and Stack and Inline for layout.

Examples

Fixed sizes

With :dynamic="false", estimateSize specifies the actual item height and DOM measurement is disabled. A function (item, index) => number supports known, differing sizes. Sizes include item padding and borders, but exclude gap. Content must fit within its declared size.

  • Item 11 / 1000
  • Item 22 / 1000
  • Item 33 / 1000
  • Item 44 / 1000
  • Item 55 / 1000
  • Item 66 / 1000
  • Item 77 / 1000
  • Item 88 / 1000
  • Item 99 / 1000
  • Item 1010 / 1000
<script setup lang="ts">
  import { VirtualList, Inline, Text } from '@hina-ui/vue'
  const items = Array.from({ length: 1000 }, (_, id) => ({ id, label: `Item ${id + 1}` }))
</script>

<template>
  <VirtualList
    :items="items"
    :get-key="item => item.id"
    :height="240"
    :estimate-size="48"
    :dynamic="false"
    label="Fixed-size list"
    class="border-line rounded-lg border"
  >
    <template #default="{ item, index }">
      <Inline justify="between" gap="sm" :wrap="false" class="border-line h-full border-b px-4">
        <Text as="span" size="sm">{{ item.label }}</Text>
        <Text as="span" size="sm" tone="muted" class="tabular-nums">
          {{ index + 1 }} / {{ items.length }}
        </Text>
      </Inline>
    </template>
  </VirtualList>
</template>
vue

Dynamic sizes

dynamic is enabled by default. Items grow with their content, and estimateSize estimates unmeasured items. Choose a value close to their expected size. Expanding content, loaded images, and viewport resizing update item measurements and subsequent positions. The total scroll extent changes as estimates are refined.

Use gap for spacing between items and paddingStart / paddingEnd for leading and trailing space, all in pixels. Avoid item margins for this spacing: margins are outside the measured item box. This example uses Collapsible to expand content, with the list tracking its animated height. Open state is stored outside each item by key and survives scrolling out of view and back.

  • Item 1

    Text wraps naturally and determines the height of each item.

  • Item 2

    Text wraps naturally and determines the height of each item. Text wraps naturally and determines the height of each item.

  • Item 3

    Text wraps naturally and determines the height of each item. Text wraps naturally and determines the height of each item. Text wraps naturally and determines the height of each item.

  • Item 4

    Text wraps naturally and determines the height of each item.

  • Item 5

    Text wraps naturally and determines the height of each item. Text wraps naturally and determines the height of each item.

  • Item 6

    Text wraps naturally and determines the height of each item. Text wraps naturally and determines the height of each item. Text wraps naturally and determines the height of each item.

  • Item 7

    Text wraps naturally and determines the height of each item.

  • Item 8

    Text wraps naturally and determines the height of each item. Text wraps naturally and determines the height of each item.

<script setup lang="ts">
  import { ref } from 'vue'
  import {
    VirtualList,
    Collapsible,
    CollapsibleTrigger,
    CollapsibleContent,
    Text,
  } from '@hina-ui/vue'

  const expanded = ref<Record<number, boolean>>({})
  const items = Array.from({ length: 500 }, (_, id) => ({
    id,
    title: `Item ${id + 1}`,
    description: 'Text wraps naturally and determines the height of each item. '.repeat(
      (id % 3) + 1,
    ),
  }))
</script>

<template>
  <VirtualList
    :items="items"
    :get-key="item => item.id"
    :estimate-size="140"
    :height="360"
    label="Dynamic-size list"
    class="border-line rounded-lg border"
  >
    <template #default="{ item }">
      <Collapsible v-model:open="expanded[item.id]" class="border-line border-b p-4">
        <Text size="sm" weight="medium">{{ item.title }}</Text>
        <Text size="sm" tone="muted" class="mt-1">{{ item.description }}</Text>
        <CollapsibleTrigger class="mt-2">
          {{ expanded[item.id] ? 'Collapse' : 'Expand' }}
        </CollapsibleTrigger>
        <CollapsibleContent>
          <Text size="sm" tone="muted" class="pt-2">
            {{ 'Expanded content is measured automatically. '.repeat(5) }}
          </Text>
        </CollapsibleContent>
      </Collapsible>
    </template>
  </VirtualList>
</template>
vue

Scrolling and visible ranges

Use a component reference to call scrollToIndex(index, { align, behavior }) or scrollToOffset(offset, { behavior }). Indexes start at 0. Alignment supports start, center, end, and auto; the default auto scrolls only when the target is outside the viewport. Out-of-range indexes are clamped to the first or last item.

behavior="smooth" enables smooth scrolling, falling back to immediate scrolling when reduced motion is requested. Long jumps with dynamic sizes continue to adjust as the target area is measured. Use fixed mode when exact sizes are known.

rangeChange reports the first and last visible indexes, excluding overscan and additional items retained for focus. It can drive incremental loading; request state and whether more data exists remain under your control. This example uses NumberInput to choose an item.

Visible 1–1
  • Item 1

  • Item 2

  • Item 3

  • Item 4

  • Item 5

  • Item 6

  • Item 7

  • Item 8

  • Item 9

  • Item 10

  • Item 11

<script setup lang="ts">
  import { ref } from 'vue'
  import {
    VirtualList,
    NumberInput,
    Button,
    Inline,
    Stack,
    Text,
    type VirtualListExpose,
    type VirtualListRange,
  } from '@hina-ui/vue'

  const list = ref<VirtualListExpose>()
  const target = ref(5000)
  const range = ref<VirtualListRange>({ startIndex: 0, endIndex: 0 })
  const items = Array.from({ length: 10000 }, (_, id) => ({ id, label: `Item ${id + 1}` }))
</script>

<template>
  <Stack class="w-full">
    <Inline gap="sm">
      <NumberInput
        v-model="target"
        :min="1"
        :max="items.length"
        aria-label="Item number"
        class="w-36"
      />
      <Button @click="list?.scrollToIndex(target - 1, { align: 'center' })">Scroll to item</Button>
      <Button variant="outline" @click="list?.scrollToOffset(0)">Back to top</Button>
      <Text as="span" size="sm" tone="muted" class="tabular-nums">
        Visible {{ range.startIndex + 1 }}–{{ range.endIndex + 1 }}
      </Text>
    </Inline>
    <VirtualList
      ref="list"
      :items="items"
      :get-key="item => item.id"
      :dynamic="false"
      :estimate-size="48"
      :height="288"
      label="Scrollable list"
      class="border-line rounded-lg border"
      @range-change="range = $event"
    >
      <template #default="{ item }">
        <Inline gap="none" class="border-line h-full border-b px-4">
          <Text size="sm">{{ item.label }}</Text>
        </Inline>
      </template>
    </VirtualList>
  </Stack>
</template>
vue

Horizontal and RTL

With orientation="horizontal", estimateSize describes width and height controls the container height. In dynamic mode, give slot content its natural width; fixed mode uses the declared width. Direction can be inherited or set with dir. RTL items run from right to left; scrolling methods still accept positive logical offsets.

ltr

  • 01

    Item 1

  • 02

    Item 2

  • 03

    Item 3

  • 04

    Item 4

  • 05

    Item 5

  • 06

    Item 6

  • 07

    Item 7

rtl

  • 01

    Item 1

  • 02

    Item 2

  • 03

    Item 3

  • 04

    Item 4

  • 05

    Item 5

  • 06

    Item 6

  • 07

    Item 7

<script setup lang="ts">
  import { VirtualList, Stack, Text } from '@hina-ui/vue'
  const items = Array.from({ length: 200 }, (_, id) => ({ id, label: `Item ${id + 1}` }))
</script>

<template>
  <Stack class="w-full">
    <Stack v-for="dir in ['ltr', 'rtl'] as const" :key="dir" gap="sm">
      <Text size="xs" tone="muted" class="uppercase">{{ dir }}</Text>
      <VirtualList
        :items="items"
        :get-key="item => item.id"
        orientation="horizontal"
        :dir="dir"
        :height="144"
        :estimate-size="160"
        :dynamic="false"
        :gap="12"
        :label="`${dir} Horizontal list`"
      >
        <template #default="{ item, index }">
          <Stack
            justify="between"
            gap="sm"
            class="border-line bg-surface h-full rounded-lg border p-4"
          >
            <Text size="2xl" tone="muted" class="tabular-nums">
              {{ String(index + 1).padStart(2, '0') }}
            </Text>
            <Text size="sm">{{ item.label }}</Text>
          </Stack>
        </template>
      </VirtualList>
    </Stack>
  </Stack>
</template>
vue

Loading and empty states

loading uses LoadingOverlay to center an indicator over the list while preserving existing items, scroll position, and viewport dimensions. The indicator is also centered when there are no items, and empty content is hidden during loading. Use #loading to replace the overlay content, #empty to replace empty content, or emptyText to change the default empty message.

The example combines Switch and Empty. The component does not fetch or clear data.

  • Item 1

  • Item 2

  • Item 3

  • Item 4

  • Item 5

  • Item 6

  • Item 7

  • Item 8

  • Item 9

  • Item 10

<script setup lang="ts">
  import { computed, ref } from 'vue'
  import { VirtualList, Button, Switch, Empty, Inline, Stack, Text } from '@hina-ui/vue'

  const loading = ref(false)
  const empty = ref(false)
  const items = computed(() =>
    empty.value ? [] : Array.from({ length: 100 }, (_, id) => ({ id, label: `Item ${id + 1}` })),
  )
</script>

<template>
  <Stack class="w-full">
    <Inline>
      <Switch v-model="loading">Loading</Switch>
      <Button variant="outline" @click="empty = !empty">
        {{ empty ? 'Restore items' : 'Clear items' }}
      </Button>
    </Inline>
    <VirtualList
      :items="items"
      :get-key="item => item.id"
      :loading="loading"
      :height="240"
      :estimate-size="48"
      :dynamic="false"
      label="List states"
      class="border-line rounded-lg border"
    >
      <template #default="{ item }">
        <Inline gap="none" class="border-line h-full border-b px-4">
          <Text size="sm">{{ item.label }}</Text>
        </Inline>
      </template>
      <template #empty>
        <Empty title="No items" description="There are no items to display." size="sm" />
      </template>
    </VirtualList>
  </Stack>
</template>
vue

SSR and measurement

The server renders an initial range using estimateSize, initialRect, and initialOffset, reserving the full estimated scroll extent. The default initial viewport is 320px wide and uses numeric height; a CSS string height uses a 320px estimate. Supply initialRect for a more accurate initial range and keep it consistent between server and client.

viewport becomes available when ScrollArea initializes. Until then, the last programmatic scrolling request is queued. initialOffset sets the initial position only; use scrolling methods for subsequent changes.

Dynamic measurements are cached by stable key. Call measure() to clear this cache and remeasure mounted items after bulk changes to content that is currently unmounted.

Accessibility

  • The scroll region is focusable, named with label, and supports native keyboard scrolling.
  • Content uses list / listitem semantics. aria-posinset and aria-setsize describe each item’s position within the full collection. There are no implicit selection or menu semantics.
  • An item containing focus stays mounted while scrolling until focus leaves it. Removing that item from the data still unmounts it.
  • Other offscreen items unmount. Keep persistent input values or expansion state outside the item, indexed by stable key.
  • Page search and assistive technology can access only mounted content. Use a regular List or pagination when all content must be available together.

API

Props

Prop
Type
Default
Description
items
readonly T[]
Required
Complete data array
getKey
(item: T, index: number) => string | number
Required
Unique, stable item key
estimateSize
number | ((item: T, index: number) => number)
48
Positive pixel size; an estimate in dynamic mode
dynamic
boolean
true
Measure item sizes automatically
height
number | string
320
Pixel height or CSS length
orientation
'vertical' | 'horizontal'
'vertical'
Scrolling axis
dir
'ltr' | 'rtl'
Inherited
Content direction
overscan
number
5
Extra items on each side of the visible range
gap
number
0
Item spacing in pixels
paddingStart
number
0
Leading space in pixels
paddingEnd
number
0
Trailing space in pixels
initialRect
{ width: number; height: number }
See above
Initial viewport dimensions for SSR
initialOffset
number
0
Initial logical scroll offset in pixels
loading
boolean
false
Loading indicator and aria-busy
emptyText
string
Locale
Default empty message
label
string
Locale
Accessible scroll region name
shadow
boolean
true
ScrollArea edge shadows
class
string
—
Root classes
itemClass
string | ((item: T, index: number) => string | undefined)
—
Item wrapper classes

Other native attributes reach the root. Customize item appearance with itemClass without overriding positioning properties. Vertical padding is included in dynamic measurements.

Slots

Slot
Props
Description
default
{ item: T, index: number }
Item content
empty
—
No items and not loading
loading
—
Loading content

Events

Event
Payload
Description
rangeChange
{ startIndex: number; endIndex: number }
Emitted initially and when the visible range changes; both indexes are -1 when no items are visible

Expose

Name
Type
Description
viewport
HTMLElement | undefined
Actual scrolling element
scrollToIndex
(index, options?: VirtualListScrollOptions) => void
Scroll to an item
scrollToOffset
(offset, options?: { behavior?: 'auto' | 'smooth' }) => void
Scroll to a logical offset
measure
() => void
Reset cached sizes and remeasure

Also exports VirtualListProps<T>, VirtualListSlotProps<T>, VirtualListKey, VirtualListRange, VirtualListScrollOptions, and VirtualListExpose.

Component integration

Select, MultiSelect, Combobox, MultiCombobox, Listbox, CommandPalette, Tree, TreeSelect, DataList, DataTable provide optional virtualize support with the same scrolling foundation and their own selection, search and keyboard behavior. They do not need an additional VirtualList wrapper.