Skip to content

VirtualGrid & VirtualTree

VirtualGrid

A virtual grid that arranges items in rows and columns. Column count can be fixed or auto-calculated from columnWidth and the container width.

Props

PropTypeDefaultDescription
itemsT[]Data array
columnsnumber0Fixed column count. Pass 0 to auto-compute from columnWidth
columnWidthnumber200Cell width for auto-column calculation
rowHeightnumber200Cell height in px — the initial estimate when dynamicRowHeight is on
gapnumber8Gap between cells in px
keyFieldstring'id'Key field
overscannumber2Extra rows outside the viewport
isLoadingbooleanfalseShows skeleton when items is empty
motionBlurbooleanfalseApply a CSS blur that scales with scroll velocity while scrolling fast
dynamicRowHeightbooleanfalseMeasure each row's actual height via ResizeObserver instead of a fixed rowHeight (row height = max of that row's cells)

Slots

SlotScopeDescription
#default{ item: T, index: number, row: number, col: number }Cell content
#emptyShown when items is empty
#skeletonShown when empty and isLoading

Emits

scroll, visible-range-change.

Example

vue
<script setup lang="ts">
import { VirtualGrid } from 'vue-virtual-scroller-kit'

interface Photo {
  id: number
  url: string
  title: string
}

const photos: Photo[] = Array.from({ length: 10_000 }, (_, i) => ({
  id: i,
  url: `https://picsum.photos/seed/${i}/200/200`,
  title: `Photo ${i + 1}`,
}))
</script>

<template>
  <VirtualGrid
    :items="photos"
    :column-width="220"
    :row-height="220"
    :gap="12"
    style="height: 600px"
  >
    <template #default="{ item }">
      <div class="photo-card">
        <img :src="item.url" :alt="item.title" />
        <p>{{ item.title }}</p>
      </div>
    </template>
  </VirtualGrid>
</template>

Dynamic row height — when cell content height varies (e.g. captions of different lengths), row height is measured per row instead of fixed:

vue
<VirtualGrid
  :items="photos"
  :column-width="220"
  :row-height="220"
  dynamic-row-height
  :gap="12"
  style="height: 600px"
>
  <template #default="{ item }">
    <div class="photo-card" style="height: auto">
      <img :src="item.url" :alt="item.title" />
      <p>{{ item.title }}</p>
      <p v-if="item.caption" class="photo-card__caption">{{ item.caption }}</p>
    </div>
  </template>
</VirtualGrid>

With dynamicRowHeight, cells no longer get a fixed height from the grid — give them height: auto (or leave height unset) so their content determines the row's real height.

VirtualTree

A tree view with expand/collapse, configurable indent per depth level, and optional lazy (async) child loading.

Props

PropTypeDefaultDescription
nodesTreeNode<T>[]Root nodes
indentnumber20Pixel indent per depth level
estimatedItemSizenumber36Estimated row height
overscannumber5Extra rows outside the viewport
onLoadChildren(node) => Promise<TreeNode<T>[]>Called when a node with hasChildren: true is first expanded
motionBlurbooleanfalseApply a CSS blur that scales with scroll velocity while scrolling fast

TreeNode<T>

ts
interface TreeNode<T extends Record<string, unknown> = Record<string, unknown>> {
  id: string | number
  data: T
  children?: TreeNode<T>[]
  hasChildren?: boolean // true = has children that haven't been loaded yet
}

FlatTreeRow<T>

The slot scope exposes:

ts
interface FlatTreeRow<T> {
  node: TreeNode<T>
  depth: number
  isExpanded: boolean
  hasChildren: boolean
  isLoading: boolean // true while onLoadChildren is running
}

Slots

SlotScopeDescription
#default{ row: FlatTreeRow<T>, index: number }Custom row content. The toggle button is rendered by the component.
#emptyEmpty state

Emits

EventPayload
node-expandTreeNode<T>
node-collapseTreeNode<T>
node-click[TreeNode<T>, depth: number]

Exposed API

ts
treeRef.value?.expandAll()
treeRef.value?.collapseAll()
treeRef.value?.scrollTo(index)
treeRef.value?.expandedIds // Readonly<Ref<Set<string | number>>>

Example

vue
<script setup lang="ts">
import { VirtualTree } from 'vue-virtual-scroller-kit'
import type { TreeNode, FlatTreeRow } from 'vue-virtual-scroller-kit'

interface FileNode {
  name: string
  type: 'file' | 'folder'
}

const nodes: TreeNode<FileNode>[] = [
  {
    id: 1,
    data: { name: 'src', type: 'folder' },
    children: [
      { id: 2, data: { name: 'main.ts', type: 'file' } },
      { id: 3, data: { name: 'App.vue', type: 'file' } },
    ],
  },
  {
    id: 4,
    data: { name: 'node_modules', type: 'folder' },
    hasChildren: true, // lazy-loaded
  },
]

async function loadChildren(node: TreeNode<FileNode>): Promise<TreeNode<FileNode>[]> {
  const res = await fetch(`/api/children/${node.id}`)
  return res.json()
}
</script>

<template>
  <VirtualTree
    :nodes="nodes"
    :indent="20"
    :on-load-children="loadChildren"
    style="height: 400px"
    @node-click="(node, depth) => console.log(node.data.name, depth)"
  >
    <template #default="{ row }">
      <span>{{ row.node.data.type === 'folder' ? '📁' : '📄' }} {{ row.node.data.name }}</span>
    </template>
  </VirtualTree>
</template>