Virtual list

A very long list that only draws the rows in view, so thousands of items stay fast.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add virtual-list
pnpm dlx ermineui add virtual-list
yarn dlx ermineui add virtual-list
bunx ermineui add virtual-list

That copies 2 files into src/components/ui/virtual-list/. Files you already have are kept.

Use

import { VirtualList } from '@/components/ui/virtual-list'
<EventCalendar v-model:month="month" :events="[
  { id: '1', title: 'Release 2.5', start: '2026-10-06', color: 1 },
  { id: '2', title: 'On-call', start: '2026-10-08', end: '2026-10-11', color: 2 },
]" @select="openEvent" @select-date="openDay" />

<Kanban v-model="columns" label="Sprint board" @move="save" />
<!-- columns: [{ id: 'todo', title: 'To do', cards: [{ id: 'k1', title: 'Gift cards' }] }] -->

<VirtualList :items="rows" :item-height="28" style="height: 20rem">
  <template #default="{ item }">{{ item.text }}</template>
</VirtualList>

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.

What gets copied

FileWhere it goes
VirtualList.vuesrc/components/ui/virtual-list/VirtualList.vue
index.tssrc/components/ui/virtual-list/index.ts

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

VirtualList.vue 108 lines
<script setup lang="ts" generic="T">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'

const props = withDefaults(
  defineProps<{
    items: readonly T[]
    /** Height of every row, in px. All rows must be the same height. */
    itemHeight: number
    /** Extra rows drawn above and below what is in view, so fast scrolling does not show gaps. */
    overscan?: number
    /** What the list holds, for screen readers. */
    label?: string
  }>(),
  { overscan: 4 },
)

defineSlots<{
  default: (props: { item: T; index: number }) => unknown
}>()

const root = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const viewport = ref(0)

let observer: ResizeObserver | undefined
onMounted(() => {
  if (!root.value) return
  viewport.value = root.value.clientHeight
  observer = new ResizeObserver(() => {
    viewport.value = root.value?.clientHeight ?? 0
  })
  observer.observe(root.value)
})
onBeforeUnmount(() => observer?.disconnect())

const range = computed(() => {
  const start = Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.overscan)
  const end = Math.min(props.items.length, Math.ceil((scrollTop.value + viewport.value) / props.itemHeight) + props.overscan)
  return { start, end }
})
const rows = computed(() => props.items.slice(range.value.start, range.value.end).map((item, offset) => ({ item, index: range.value.start + offset })))

function onScroll() {
  scrollTop.value = root.value?.scrollTop ?? 0
}

/** Scroll so that the row at `index` is in view. */
function scrollToIndex(index: number) {
  root.value?.scrollTo({ top: Math.max(0, Math.min(index, props.items.length - 1)) * props.itemHeight })
}

defineExpose({ scrollToIndex })
</script>

<template>
  <!-- Give it a height (in CSS or a style attribute); it scrolls inside that. -->
  <div ref="root" class="er-virtual-list" role="list" tabindex="0" :aria-label="label" @scroll.passive="onScroll">
    <!-- One tall spacer holds the full scroll height; the few real rows are shifted into place inside it. -->
    <div class="er-virtual-list-spacer" :style="{ height: `${items.length * itemHeight}px` }">
      <div :style="{ transform: `translateY(${range.start * itemHeight}px)` }">
        <!-- Each row says where it sits in the whole list, since most of the list is not in the page. -->
        <div
          v-for="row in rows"
          :key="row.index"
          role="listitem"
          class="er-virtual-list-item"
          :aria-setsize="items.length"
          :aria-posinset="row.index + 1"
          :style="{ height: `${itemHeight}px` }"
        >
          <slot :item="row.item" :index="row.index" />
        </div>
      </div>
    </div>
  </div>
</template>

<style>
@layer components {
  .er-virtual-list {
    position: relative;
    height: 20rem;
    max-height: 100%;
    border-radius: var(--er-radius-md);
    overflow-y: auto;
    overscroll-behavior: contain;
    scrollbar-width: thin;
    scrollbar-color: var(--er-input) transparent;
  }

  .er-virtual-list:focus-visible {
    outline: 2px solid var(--er-ring);
    outline-offset: 2px;
  }

  .er-virtual-list-spacer {
    overflow: hidden;
  }

  .er-virtual-list-item {
    display: flex;
    align-items: center;
    min-width: 0;
    overflow: hidden;
  }
}
</style>
index.ts 3 lines
/** Virtual list — a very long list that only draws the rows in view, so thousands of items stay fast. */
export { default as VirtualList } from './VirtualList.vue'