VirtualTable
A virtual table rendered as a native <table> element. The component uses <thead> / <tbody> / <tfoot> with two spacer rows (top and bottom) to create the virtual scroll effect while keeping the browser's built-in column width synchronisation between header and body. Row heights are measured by ResizeObserver after each render, so rows can have arbitrary content.
Features: sticky header, fixed left/right columns, single/multi-column sort with sort-stack indicator, optional drag-to-resize columns, horizontal column virtualization, pinned top/bottom rows, and built-in infinite scroll (onLoadMore).
Architecture
<div class="vvsk-table"> ← scroll container (overflow: auto)
<table>
<colgroup> ← column widths, auto-syncs header ↔ body
<thead> ← position: sticky top; contains header row
<tr> … <th> … ← column headers (sort on click)
<tr> … <td> … ← pinnedTopRows (always visible, sticky)
<tbody>
<tr class="spacer"> ← top virtual space (height = offsetTop)
<tr v-for visibleRows> ← only rendered rows
<tr class="spacer"> ← bottom virtual space
<tfoot> ← position: sticky bottom; pinnedBottomRowsThe browser's native scroll anchoring (
overflow-anchor) is disabled on.vvsk-tablebecause it conflicts with the spacer-row virtual-scroll technique. Instead,useVirtualScrollperforms its own anchor compensation: when a row above the viewport is measured to a different height, the scroll position is nudged by the same delta so visible rows never jump.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
columns | ColumnDef[] | — | Column definitions |
rows | T[] | — | Data rows |
estimatedItemSize | number | 40 | Estimated row height used before measurement |
stickyHeader | boolean | true | Pin the header row to the top |
stickyHeaderOffset | number | 0 | Top offset for the sticky header (e.g. navbar height) |
sortable | boolean | false | Enable single-column sort on header click |
multiSort | boolean | false | Enable Shift+click multi-column sort |
virtualizeColumns | boolean | false | Render only horizontally visible columns (for 50+ columns) |
resizableColumns | boolean | false | Allow drag-to-resize column borders |
reorderableColumns | boolean | false | Allow dragging a whole header to reorder columns (independent of resizableColumns) |
pinnedTopRows | T[] | [] | Rows pinned inside <thead>, always visible at the top |
pinnedBottomRows | T[] | [] | Rows pinned inside <tfoot>, always visible at the bottom |
overscan | number | 3 | Extra rows rendered outside the viewport |
keyField | string | 'id' | Row key field (must be unique per row) |
onLoadMore | () => void | — | Called when user scrolls near the bottom and hasMore is true |
hasMore | boolean | false | Whether more rows are available to load |
isLoading | boolean | false | Whether a load is in progress (prevents duplicate calls) |
loadMoreThreshold | number | 150 | Distance from the bottom edge in px that triggers onLoadMore |
uniformRowHeight | boolean | false | All rows have identical height — disables ResizeObserver to prevent scroll drift. Set estimatedItemSize to the exact row height |
motionBlur | boolean | false | Apply a CSS blur that scales with scroll velocity while scrolling fast |
ColumnDef
interface ColumnDef {
key: string // matches the row object property
title: string // header label
width?: number // column width in px (fallback: minWidth ?? 100)
minWidth?: number // minimum width after drag-resize
maxWidth?: number // maximum width after drag-resize
fixed?: 'left' | 'right' // sticky fixed column
}Slots
| Slot | Scope | Description |
|---|---|---|
#header-cell | { column: ColumnDef } | Custom header cell content. Default renders title + sort arrow (↑/↓) for the active sort column only |
#row | { row: T, index: number } | Replace entire <tr> rendering |
#cell | { row: T, column: ColumnDef, value: unknown, index: number } | Custom cell content |
#pinned-row | { row: T, index: number, position: 'top' | 'bottom' } | Replace entire pinned <tr> |
#pinned-cell | { row: T, column: ColumnDef, value: unknown, position: 'top' | 'bottom', index: number } | Custom pinned cell content |
#loading-indicator | — | Shown below the table when isLoading && hasMore |
Emits
| Event | Payload | Description |
|---|---|---|
sort-change | SortChange | SortChange[] | Single sort object (sortable), or array (multiSort) |
column-resize | [key: string, width: number] | Fires after a column drag-resize ends |
column-reorder | [order: string[]] | Fires after a column drag-reorder ends, with the new column key order |
column-visibility-change | { key: string; visible: boolean } | Fires after setColumnVisible/toggleColumnVisible changes a column's visibility |
scroll | Event | Native scroll event from the container |
visible-range-change | { start: number; end: number } | Fires on scroll with the current visible row indices |
SortChange
interface SortChange {
key: string
direction: 'asc' | 'desc' | null
}Exposed API
import type { VirtualListExpose } from 'vue-virtual-scroller-kit'
const tableRef = ref<
| (VirtualListExpose & {
getSortStack: () => SortChange[]
clearSort: () => void
setColumnVisible: (key: string, visible: boolean) => void
toggleColumnVisible: (key: string) => void
getHiddenColumns: () => string[]
})
| null
>(null)
tableRef.value?.scrollTo(rowIndex, 'start') // 'start' | 'center' | 'end' | 'auto'
tableRef.value?.scrollTo(rowIndex, 'start', { behavior: 'smooth' })
tableRef.value?.scrollToOffset(px)
tableRef.value?.clearSort()
tableRef.value?.getSortStack() // current sort state
tableRef.value?.getScrollElement() // pair with VirtualScrollbar
tableRef.value?.toggleColumnVisible('email') // hide/show a column at runtime
tableRef.value?.setColumnVisible('email', false)
tableRef.value?.getHiddenColumns() // current hidden column keysHiding a column is runtime/interactive state — like sort or column order — so it's exposed via the template ref rather than a prop. Hidden columns are dropped from rendering, column virtualization, and fixed-column offset math uniformly.
CSS custom property
Fixed columns and pinned rows use --vvsk-sticky-bg for their cell background to cover scrolling content behind them. Set it on the table element to match your theme:
.my-table {
--vvsk-sticky-bg: var(--surface-color);
}Default fallback is #fff.
Examples
Basic — sort, fixed columns, custom cells, resizable columns:
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualTable } from 'vue-virtual-scroller-kit'
import type { ColumnDef, SortChange } from 'vue-virtual-scroller-kit'
interface User {
id: number
name: string
email: string
age: number
}
const originalRows: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com', age: 28 },
{ id: 2, name: 'Bob', email: 'bob@example.com', age: 35 },
// …
]
const columns: ColumnDef[] = [
{ key: 'id', title: '#', width: 60, fixed: 'left' },
{ key: 'name', title: 'Name', width: 180 },
{ key: 'email', title: 'Email', minWidth: 200 },
{ key: 'age', title: 'Age', width: 80 },
]
const rows = ref<User[]>([...originalRows])
function onSort(sort: SortChange | SortChange[]) {
const s = Array.isArray(sort) ? sort[0] : sort
if (!s || !s.direction) {
rows.value = [...originalRows]
return
}
rows.value = [...rows.value].sort((a, b) =>
s.direction === 'asc'
? String(a[s.key as keyof User]).localeCompare(String(b[s.key as keyof User]))
: String(b[s.key as keyof User]).localeCompare(String(a[s.key as keyof User])),
)
}
</script>
<template>
<VirtualTable
:columns="columns"
:rows="rows"
key-field="id"
sortable
resizable-columns
style="height: 500px"
@sort-change="onSort"
>
<template #cell="{ column, value }">
<span
v-if="column.key === 'age'"
:style="{ color: (value as number) < 30 ? 'green' : 'inherit' }"
>
{{ value }}
</span>
<span v-else>{{ value }}</span>
</template>
</VirtualTable>
</template>Multi-column sort (Shift+click to add columns to sort stack):
<VirtualTable
:columns="columns"
:rows="rows"
multi-sort
style="height: 500px"
@sort-change="onMultiSort"
/>function onMultiSort(sort: SortChange | SortChange[]) {
const stack = Array.isArray(sort) ? sort : [sort]
rows.value = [...rows.value].sort((a, b) => {
for (const { key, direction } of stack) {
if (!direction) continue
const cmp = String(a[key as keyof Row]).localeCompare(String(b[key as keyof Row]))
if (cmp !== 0) return direction === 'asc' ? cmp : -cmp
}
return 0
})
}Auto column widths — measure content with Canvas before rendering:
import { autoColWidths } from 'vue-virtual-scroller-kit'
import type { ColumnDef } from 'vue-virtual-scroller-kit'
const rawCols = [
{ key: 'id', title: 'ID' },
{ key: 'name', title: 'Name' },
{ key: 'email', title: 'Email' },
]
// Call after rows are loaded
const widths = autoColWidths(rawCols, rows, {
font: '12px Inter, sans-serif',
padding: 24,
maxWidth: 400,
})
const columns: ColumnDef[] = rawCols.map((c) => ({
key: c.key,
title: c.title,
width: widths.get(c.key) ?? 120,
minWidth: 60,
}))Pinned rows — rows that stay visible while the body scrolls. Top rows live in <thead> (sticky to top), bottom rows live in <tfoot> (sticky to bottom):
<script setup lang="ts">
const pinnedTop = [{ id: -1, name: '📌 Pinned', score: 0 }]
const pinnedBottom = [{ id: -2, name: '∑ Total', score: totalScore }]
</script>
<template>
<VirtualTable
:columns="columns"
:rows="rows"
:pinned-top-rows="pinnedTop"
:pinned-bottom-rows="pinnedBottom"
style="height: 500px; --vvsk-sticky-bg: #fff"
>
<template #pinned-cell="{ row, column, position }">
<strong v-if="position === 'bottom'">{{ row[column.key] }}</strong>
<span v-else>{{ row[column.key] }}</span>
</template>
</VirtualTable>
</template>Pinned cells automatically receive
background-color: var(--vvsk-sticky-bg, #fff)so they always cover the scrolling rows behind them.
Lazy loading — infinite scroll triggered when near the bottom:
<script setup lang="ts">
import { ref, computed } from 'vue'
import { VirtualTable, autoColWidths } from 'vue-virtual-scroller-kit'
import type { ColumnDef, SortChange } from 'vue-virtual-scroller-kit'
interface Row {
id: number
name: string
email: string
}
const PAGE = 100
const rows = ref<Row[]>([])
const total = ref(0)
const loading = ref(false)
const hasMore = computed(() => rows.value.length < total.value)
// Columns sized from data after first load
const colWidths = ref<Map<string, number>>(new Map())
const rawCols = [
{ key: 'id', title: '#' },
{ key: 'name', title: 'Name' },
{ key: 'email', title: 'Email' },
]
const columns = computed((): ColumnDef[] => [
...rawCols.map((c) => ({
key: c.key,
title: c.title,
width: colWidths.value.get(c.key) ?? 120,
minWidth: 60,
})),
{ key: '__actions', title: '', width: 80, fixed: 'right' as const },
])
async function fetchRows(page: number, replace: boolean) {
if (loading.value) return
loading.value = true
try {
const res = await fetch(`/api/rows?page=${page}&limit=${PAGE}`)
const data = await res.json()
rows.value = replace ? data.rows : [...rows.value, ...data.rows]
total.value = data.total
if (replace)
colWidths.value = autoColWidths(rawCols, rows.value, { font: '12px Inter, sans-serif' })
} finally {
loading.value = false
}
}
function loadMore() {
fetchRows(Math.floor(rows.value.length / PAGE) + 1, false)
}
function onSort(sort: SortChange | SortChange[]) {
rows.value = []
fetchRows(1, true)
}
fetchRows(1, true)
</script>
<template>
<VirtualTable
:columns="columns"
:rows="rows"
key-field="id"
sortable
resizable-columns
:on-load-more="loadMore"
:has-more="hasMore"
:is-loading="loading"
:load-more-threshold="200"
style="height: 600px; --vvsk-sticky-bg: #fff"
@sort-change="onSort"
>
<template #cell="{ row, column }">
<template v-if="column.key === '__actions'">
<button @click="edit(row)">✏</button>
<button @click="remove(row)">✕</button>
</template>
<span v-else>{{ row[column.key as keyof Row] }}</span>
</template>
<template #loading-indicator>
<div style="padding: 12px; text-align: center; opacity: 0.5">Loading…</div>
</template>
</VirtualTable>
</template>Column virtualization — for very wide tables (100+ columns), render only visible columns:
<VirtualTable :columns="columns" :rows="rows" :virtualize-columns="true" style="height: 500px" />Drag-to-reorder columns — drag a whole header to move it; independent of resizableColumns (dragging the resize handle at the column edge never triggers a reorder):
<script setup lang="ts">
function onColumnReorder(order: string[]) {
// Persist the new column order, e.g. to localStorage.
localStorage.setItem('table-column-order', JSON.stringify(order))
}
</script>
<template>
<VirtualTable
:columns="columns"
:rows="rows"
reorderable-columns
style="height: 500px"
@column-reorder="onColumnReorder"
/>
</template>Column order is tracked internally (like
resizableColumnswidths) and not written back to yourcolumnsprop — listen forcolumn-reorderif you want to persist it.
Column show/hide — a checklist toggling columns at runtime:
<script setup lang="ts">
import { ref } from 'vue'
import { VirtualTable } from 'vue-virtual-scroller-kit'
const tableRef = ref<InstanceType<typeof VirtualTable> | null>(null)
</script>
<template>
<label v-for="col in columns" :key="col.key">
<input type="checkbox" checked @change="tableRef?.toggleColumnVisible(col.key)" />
{{ col.title }}
</label>
<VirtualTable ref="tableRef" :columns="columns" :rows="rows" style="height: 500px" />
</template>Like
columnOrder, hidden state lives in the component (not written back tocolumns) — listen forcolumn-visibility-changeto persist it.
Row selection with checkboxes — pairs useRowSelection with the index now exposed on #cell/#pinned-cell. Click to toggle, Shift+click to select a range:
<script setup lang="ts">
import { computed } from 'vue'
import { VirtualTable, useRowSelection } from 'vue-virtual-scroller-kit'
import type { ColumnDef } from 'vue-virtual-scroller-kit'
interface User {
id: number
name: string
email: string
}
const rows = ref<User[]>([/* … */])
const selection = useRowSelection<User>({ items: rows })
const columns: ColumnDef[] = [
{ key: '__select', title: '', width: 36, fixed: 'left' },
{ key: 'name', title: 'Name' },
{ key: 'email', title: 'Email' },
]
</script>
<template>
<VirtualTable :columns="columns" :rows="rows" key-field="id" style="height: 500px">
<template #cell="{ column, row, index }">
<input
v-if="column.key === '__select'"
type="checkbox"
:checked="selection.isSelected(row, index)"
@click="selection.toggle(row, index, $event)"
/>
<span v-else>{{ row[column.key as keyof User] }}</span>
</template>
</VirtualTable>
<p>{{ selection.selectedItems.value.length }} selected</p>
</template>