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
| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | — | Data array |
columns | number | 0 | Fixed column count. Pass 0 to auto-compute from columnWidth |
columnWidth | number | 200 | Cell width for auto-column calculation |
rowHeight | number | 200 | Cell height in px — the initial estimate when dynamicRowHeight is on |
gap | number | 8 | Gap between cells in px |
keyField | string | 'id' | Key field |
overscan | number | 2 | Extra rows outside the viewport |
isLoading | boolean | false | Shows skeleton when items is empty |
motionBlur | boolean | false | Apply a CSS blur that scales with scroll velocity while scrolling fast |
dynamicRowHeight | boolean | false | Measure each row's actual height via ResizeObserver instead of a fixed rowHeight (row height = max of that row's cells) |
Slots
| Slot | Scope | Description |
|---|---|---|
#default | { item: T, index: number, row: number, col: number } | Cell content |
#empty | — | Shown when items is empty |
#skeleton | — | Shown 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 fixedheightfrom the grid — give themheight: auto(or leaveheightunset) 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
| Prop | Type | Default | Description |
|---|---|---|---|
nodes | TreeNode<T>[] | — | Root nodes |
indent | number | 20 | Pixel indent per depth level |
estimatedItemSize | number | 36 | Estimated row height |
overscan | number | 5 | Extra rows outside the viewport |
onLoadChildren | (node) => Promise<TreeNode<T>[]> | — | Called when a node with hasChildren: true is first expanded |
motionBlur | boolean | false | Apply 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
| Slot | Scope | Description |
|---|---|---|
#default | { row: FlatTreeRow<T>, index: number } | Custom row content. The toggle button is rendered by the component. |
#empty | — | Empty state |
Emits
| Event | Payload |
|---|---|
node-expand | TreeNode<T> |
node-collapse | TreeNode<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>