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
columns
ColumnDef[]
Column definitions.
rows
T[]
Data rows.
estimatedItemSize
number · default: 40
Estimated row height used before measurement.
stickyHeader
boolean · default: true
Pin the header row to the top.
stickyHeaderOffset
number · default: 0
Top offset for the sticky header (e.g. navbar height).
sortable
boolean · default: false
Enable single-column sort on header click. Sortable header cells also expose their state via aria-sort ("ascending", "descending", or "none").
multiSort
boolean · default: false
Enable Shift+click multi-column sort. Also drives aria-sort on each participating header, like sortable.
virtualizeColumns
boolean · default: false
Render only horizontally visible columns (for 50+ columns).
resizableColumns
boolean · default: false
Allow drag-to-resize column borders.
reorderableColumns
boolean · default: false
Allow dragging a whole header to reorder columns (independent of resizableColumns).
pinnedTopRows
T[] · default: []
Rows pinned inside <thead>, always visible at the top.
pinnedBottomRows
T[] · default: []
Rows pinned inside <tfoot>, always visible at the bottom.
overscan
number · default: 3
Extra rows rendered outside the viewport.
keyField
string · default: 'id'
Row key field (must be unique per row).
onLoadMore
() => void
Called when the user scrolls near the bottom and hasMore is true.
hasMore
boolean · default: false
Whether more rows are available to load.
isLoading
boolean · default: false
Whether a load is in progress (prevents duplicate calls).
loadMoreThreshold
number · default: 150
Distance from the bottom edge in px that triggers onLoadMore.
uniformRowHeight
boolean · default: false
All rows have identical height — disables ResizeObserver to prevent scroll drift. Set estimatedItemSize to the exact row height.
motionBlur
boolean · default: false
Apply a CSS blur that scales with scroll velocity while scrolling fast.
Column Definition
ColumnDef — the shape of each entry in the columns prop.
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
header-cell
Scope: { column: ColumnDef }
Custom header cell content. Default renders title + sort arrow (↑/↓) for the active sort column only.
row
Scope: { row: T, index: number }
Replace entire <tr> rendering.
cell
Scope: { row: T, column: ColumnDef, value: unknown, index: number }
Custom cell content.
pinned-row
Scope: { row: T, index: number, position: 'top' | 'bottom' }
Replace entire pinned <tr>.
pinned-cell
Scope: { row: T, column: ColumnDef, value: unknown, position: 'top' | 'bottom', index: number }
Custom pinned cell content.
loading-indicator
Scope: No scope
Shown below the table when isLoading && hasMore.
Emits
sort-change
Payload: SortChange | SortChange[]
Single sort object (sortable), or array (multiSort).
column-resize
Payload: [key: string, width: number]
Fires after a column drag-resize ends.
column-reorder
Payload: [order: string[]]
Fires after a column drag-reorder ends, with the new column key order.
column-visibility-change
Payload: { key: string; visible: boolean }
Fires after setColumnVisible/toggleColumnVisible changes a column's visibility.
scroll
Payload: Event
Native scroll event from the container.
visible-range-change
Payload: { start: number; end: number }
Fires on scroll with the current visible row indices.
Sort Event Payload
SortChange — the payload of the sort-change event.
interface SortChange {
key: string
direction: 'asc' | 'desc' | null
}Exposed API
scrollTo(rowIndex, align?, options?)
Scroll to a row. align is 'start' | 'center' | 'end' | 'auto'.
scrollToOffset(px, options?)
Scroll to a raw pixel offset.
clearSort()
Clears the current sort state.
getSortStack()
Returns the current sort state (SortChange[]).
getScrollElement()
Returns the element that actually scrolls — pair with VirtualScrollbar.
toggleColumnVisible(key)
Hide/show a column at runtime.
setColumnVisible(key, visible)
Explicitly set a column's visibility.
getHiddenColumns()
Returns the current hidden column keys.
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>