Skip to content

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; pinnedBottomRows

The browser's native scroll anchoring (overflow-anchor) is disabled on .vvsk-table because it conflicts with the spacer-row virtual-scroll technique. Instead, useVirtualScroll performs 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

PropTypeDefaultDescription
columnsColumnDef[]Column definitions
rowsT[]Data rows
estimatedItemSizenumber40Estimated row height used before measurement
stickyHeaderbooleantruePin the header row to the top
stickyHeaderOffsetnumber0Top offset for the sticky header (e.g. navbar height)
sortablebooleanfalseEnable single-column sort on header click
multiSortbooleanfalseEnable Shift+click multi-column sort
virtualizeColumnsbooleanfalseRender only horizontally visible columns (for 50+ columns)
resizableColumnsbooleanfalseAllow drag-to-resize column borders
reorderableColumnsbooleanfalseAllow dragging a whole header to reorder columns (independent of resizableColumns)
pinnedTopRowsT[][]Rows pinned inside <thead>, always visible at the top
pinnedBottomRowsT[][]Rows pinned inside <tfoot>, always visible at the bottom
overscannumber3Extra rows rendered outside the viewport
keyFieldstring'id'Row key field (must be unique per row)
onLoadMore() => voidCalled when user scrolls near the bottom and hasMore is true
hasMorebooleanfalseWhether more rows are available to load
isLoadingbooleanfalseWhether a load is in progress (prevents duplicate calls)
loadMoreThresholdnumber150Distance from the bottom edge in px that triggers onLoadMore
uniformRowHeightbooleanfalseAll rows have identical height — disables ResizeObserver to prevent scroll drift. Set estimatedItemSize to the exact row height
motionBlurbooleanfalseApply a CSS blur that scales with scroll velocity while scrolling fast

ColumnDef

ts
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

SlotScopeDescription
#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-indicatorShown below the table when isLoading && hasMore

Emits

EventPayloadDescription
sort-changeSortChange | 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
scrollEventNative scroll event from the container
visible-range-change{ start: number; end: number }Fires on scroll with the current visible row indices

SortChange

ts
interface SortChange {
  key: string
  direction: 'asc' | 'desc' | null
}

Exposed API

ts
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 keys

Hiding 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:

css
.my-table {
  --vvsk-sticky-bg: var(--surface-color);
}

Default fallback is #fff.

Examples

Basic — sort, fixed columns, custom cells, resizable columns:

vue
<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):

vue
<VirtualTable
  :columns="columns"
  :rows="rows"
  multi-sort
  style="height: 500px"
  @sort-change="onMultiSort"
/>
ts
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:

ts
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):

vue
<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:

vue
<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:

vue
<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):

vue
<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 resizableColumns widths) and not written back to your columns prop — listen for column-reorder if you want to persist it.

Column show/hide — a checklist toggling columns at runtime:

vue
<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 to columns) — listen for column-visibility-change to 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:

vue
<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>