← Blog
6 Oct 2026Vuevirtualized tabledata gridperformanceaccessibility

Virtualized Table in Vue: Build Fast Rows Without Losing Grid Interactions

Build a virtualized table in Vue with stable row IDs, a small rendered window, sorting, selection, accessibility checks, and practical performance tests.

Virtualized Table in Vue: Build Fast Rows Without Losing Grid Interactions

TL;DR

To build a virtualized table in Vue, create the final sorted and filtered row list first, then give a virtualizer that list’s length and a bounded scroll container. Render only its visible indexes plus a small buffer, but keep selection keyed by record ID and make offscreen row positions understandable to assistive technology. Virtualization reduces rendered nodes; it does not reduce the amount of data fetched or the work required to sort it.

A dashboard can display thousands of accounts yet show only a dozen rows at once. Rendering all the accounts as DOM rows makes every update, cell control, and resize harder than it needs to be. Vue’s performance guidance recommends windowing large lists so the DOM contains only the items in or near the viewport. We will apply that principle to an interactive Vue table, then test the cases that often break when a row scrolls out of the DOM.

This is a rendering guide, not a replacement for a data-grid interaction demo. DOM Studio’s Data Grid examples already show filtering, sorting, a large in-memory grid with virtual rows, and remote pagination. Here we concentrate on the contract between the row model, the window, and user interactions.

Table of contents

Before you start

We need a Vue 3 project with a build step, an array of records with immutable unique IDs, and a page where we can observe scrolling and keyboard focus. For the working example below, install the Vue adapter used in TanStack Virtual’s Vue virtualization guide:

npm install @tanstack/vue-virtual

The sample uses generated, fixed-height rows and client-side data so you can see the windowing mechanism in isolation. It assumes one 44-pixel row, one header row, no server requests, and a fixed set of three columns. Replace its generated records with your own data and define your API, loading, and authorization rules separately. The sample provides table controls, not a complete spreadsheet-style ARIA grid or a production-tested accessibility certification. We chose a Medium guide, with a runnable core followed by the integration and verification work a product engineer actually needs.

1. Decide whether rows, columns, or the data source need work

Action: Profile a representative screen before adding windowing. Open a realistic large table in a production build, count its mounted rows, then scroll, filter, and resize while recording long tasks and layout work. Vue recommends Chrome’s Performance panel and Vue DevTools to locate update costs. If the problem is a 200-row table with an expensive cell renderer, fix the cell first rather than adding scroll machinery. Vue performance guidance and the TanStack Vue virtualization guide both distinguish large-list rendering from other performance problems.

Use row virtualization when the visible column count is modest but there are many rows. Add column virtualization only when a very wide set of columns makes the number of mounted cells or horizontal layout expensive. A 10,000-row dataset with eight columns has a different bottleneck from 500 rows with 200 columns. When both dimensions are expensive, the row and column windows must derive from the same current row and visible-column models. TanStack’s guide documents that two-axis arrangement and the need to preserve horizontal width with spacer cells. TanStack Vue virtualization guide

If the complete dataset should not reside in the browser, request pages or batches from your API first. Windowing is not server-side pagination: a client-side virtualizer still needs the rows it is asked to render. DOM Studio’s data-component overview describes a server-in-the-loop pattern for queries, mutations, pagination, and refreshes. Choose a single owner for each of those operations before layering a viewport over the result.

Expected result: we can state whether the bottleneck is mounted rows, mounted columns, an expensive data transformation, or an oversized download. Troubleshooting: a smaller DOM will not make a slow database query faster; check the network timeline before changing the renderer.

2. Separate the data model from the viewport window

Action: Establish the order source records → search/filter/sort → current rows → virtual indexes → rendered cells. The scroll position must never become the source of truth for which record is selected or what an index means. The virtualizer accepts a count and row-height estimate, calculates the scroll geometry, and returns the indexes that belong in the viewport. Each index maps back to the current sorted and filtered list, not the original array. This is also the division of responsibility described in the TanStack Vue virtualization guide.

Flow diagram showing the full record set filtered into a small rendered window with selection stored separately.

For a fixed-height first pass, decide on one measured row height, a bounded viewport height, and a modest overscan. Overscan mounts neighboring rows to reduce blanking during fast scrolling, at the cost of more work per update. Use persistent record IDs for both Vue’s :key and selection state: Vue’s list-rendering guidance explains why index-based identity can misplace state after reordering. The virtualizer’s key option likewise supports deriving an item key from its record ID.

Expected result: filtering or sorting produces a new list, and virtual index zero now points to its first record. Troubleshooting: if a checked box appears on a different account after sorting, inspect the data key and the selection store, not just the checkbox component.

For a visual explanation of rendering only the visible slice in Vue, the following LearnVue walkthrough covers virtual lists. It explains the same windowing principle; our table-specific sorting, semantics, and focus rules are additional work.

3. Build the fixed-height Vue table

Action: Put this component in src/components/VirtualAccounts.vue, render it in a Vue 3 route, and scroll it from first to last row. The @tanstack/vue-virtual adapter’s useVirtualizer, getVirtualItems(), getTotalSize(), estimateSize, overscan, and scroll-element pattern follow its Vue table example and the virtualizer API. ref, shallowRef, computed, and keyed v-for are Vue 3 APIs; use shallowRef only because the example treats its source records as immutable. Vue performance guidance

<script setup lang="ts">
import { computed, ref, shallowRef } from 'vue'
import { useVirtualizer } from '@tanstack/vue-virtual'

type Account = { id: string; name: string; amount: number }
const records = shallowRef<Account[]>(
  Array.from({ length: 10_000 }, (_, index) => ({
    id: `account-${index + 1}`,
    name: `Account ${String(index + 1).padStart(5, '0')}`,
    amount: (index * 11) % 10_000,
  })),
)
const search = ref('')
const ascending = ref(true)
const selectedIds = ref(new Set<string>())
const scrollElement = ref<HTMLDivElement | null>(null)

const rows = computed(() => {
  const query = search.value.trim().toLowerCase()
  return records.value
    .filter((row) => row.name.toLowerCase().includes(query))
    .sort((a, b) => ascending.value
      ? a.name.localeCompare(b.name)
      : b.name.localeCompare(a.name))
})

const virtualizer = useVirtualizer(computed(() => ({
  count: rows.value.length,
  getScrollElement: () => scrollElement.value,
  estimateSize: () => 44,
  overscan: 5,
  getItemKey: (index: number) => rows.value[index]?.id ?? index,
})))
const visibleItems = computed(() => virtualizer.value.getVirtualItems())
const totalHeight = computed(() => virtualizer.value.getTotalSize())

function resetToStart() {
  scrollElement.value?.scrollTo({ top: 0 })
}
function changeSearch(event: Event) {
  search.value = (event.target as HTMLInputElement).value
  resetToStart()
}
function changeSort() {
  ascending.value = !ascending.value
  resetToStart()
}
function toggle(id: string) {
  const next = new Set(selectedIds.value)
  if (next.has(id)) next.delete(id)
  else next.add(id)
  selectedIds.value = next
}
</script>

<template>
  <section aria-label="Accounts">
    <label for="account-search">Search accounts</label>
    <input id="account-search" type="search" :value="search" @input="changeSearch" />
    <p aria-live="polite">{{ rows.length }} matching accounts</p>
    <div ref="scrollElement" class="viewport" tabindex="0" aria-label="Scroll accounts table">
      <table aria-label="Accounts" :aria-rowcount="rows.length + 1">
        <thead>
          <tr aria-rowindex="1">
            <th scope="col">Select</th>
            <th scope="col" :aria-sort="ascending ? 'ascending' : 'descending'">
              <button type="button" @click="changeSort">Account name</button>
            </th>
            <th scope="col">Amount</th>
          </tr>
        </thead>
        <tbody :style="{ height: `${totalHeight}px` }">
          <tr
            v-for="item in visibleItems"
            :key="item.key"
            :aria-rowindex="item.index + 2"
            :style="{ transform: `translateY(${item.start}px)` }"
          >
            <td>
              <input
                type="checkbox"
                :aria-label="`Select ${rows[item.index].name}`"
                :checked="selectedIds.has(rows[item.index].id)"
                @change="toggle(rows[item.index].id)"
              />
            </td>
            <td>{{ rows[item.index].name }}</td>
            <td>{{ rows[item.index].amount }}</td>
          </tr>
        </tbody>
      </table>
    </div>
    <p>{{ selectedIds.size }} selected</p>
  </section>
</template>

<style scoped>
.viewport { height: 440px; overflow: auto; border: 1px solid #b9c3d3; }
table { display: grid; width: 100%; min-width: 390px; }
thead { display: grid; position: sticky; top: 0; z-index: 1; background: white; }
tbody { display: block; position: relative; }
tr { display: grid; grid-template-columns: 85px minmax(0, 1fr) 110px; }
tbody tr { position: absolute; top: 0; left: 0; width: 100%; }
th, td { box-sizing: border-box; height: 44px; padding: 9px; border-bottom: 1px solid #e0e5ed; }
button, input { font: inherit; }
:focus-visible { outline: 2px solid #2259b8; outline-offset: 2px; }
</style>

The fixed height is a contract, not an estimate that can silently drift: wrapped text, variable padding, expanded details, or larger user fonts can invalidate the 44-pixel geometry. The grid layout in the CSS supports independently positioned table rows, but CSS display changes and virtualization need actual screen-reader testing rather than an assumption that native markup alone settles accessibility. Use horizontal scrolling instead of squeezing columns below their minimum readable width.

Expected result: the scrollbar covers the whole filtered set, while inspection shows only the visible rows and a small overscan in the DOM. Search for Account 00999, then sort and select it. Troubleshooting: if scrolling produces overlap or a blank area, verify the real rendered row height and check that the table’s scroll container is the element supplied to getScrollElement.

4. Keep sorting, selection, and remote queries independent

Action: Exercise the controls while scrolling. Sorting and filtering change the final row order and count, so we return to the start after either operation. Selection remains a Set of IDs and survives an item unmounting or moving to a new index. The sample’s aria-rowcount includes its single header row, and its aria-rowindex starts data rows at 2; that follows the W3C’s grid and table position guidance. If a filter hides a selected record, decide explicitly whether its selection persists and show the number of hidden selections before offering bulk actions.

Virtualized table viewport with a fixed header, selected row, and visible keyboard focus indicator.

For remote data, send sort, filter, and page or cursor parameters to the resource endpoint; virtualize only the loaded ordered records. Do not locally sort one fetched page and label it a global sort. Reset the cursor and scroll position when the query changes, prevent overlapping fetches from appending a stale response, and display loading, retry, empty, and end-of-list states. The TanStack Vue virtualization guide describes the distinction between client-side virtualization and server operations; DOM Studio’s Data Grid page shows separate large-memory and server-pagination examples. A server may not expose the total count, in which case do not invent one for accessibility announcements.

Expected result: a filtered server response and its rendered window describe the same query. Troubleshooting: if rows from two different filters appear together after a fast edit, cancel or ignore the older response using a request generation key before appending data.

5. Preserve keyboard access and focus as rows unmount

Action: Test the example using Tab, the search field, sort button, and visible checkboxes without a mouse. The W3C distinguishes a table, which presents tabular information, from an interactive grid, whose cells follow a defined arrow-key navigation contract. Adding role="grid" without implementing that contract makes navigation less predictable, not more accessible. If your product needs cell-to-cell arrows, roving focus, Home/End, and action mode for controls inside cells, implement and test the WAI-ARIA grid pattern separately. The example deliberately remains a table.

Windowing introduces a focus failure: if someone tabs to a row control and then a programmatic scroll removes that row, the focused element can disappear. Before shipping, store the focused record ID and column ID, ensure a target row is loaded, scroll it into the virtual window, and move focus to its new mounted control after rendering. Do not restore focus by old row index after sorting. For a grid, also keep aria-rowcount, aria-rowindex, and, when columns are virtualized, correct column counts and indexes. W3C warns that inconsistent row indexes can cause assistive-technology reading to skip or stop. W3C grid and table properties

Selection is not focus. A selected account may stay selected while keyboard focus moves to a different cell, and nested menus or inputs need a clear rule for whether arrow keys navigate the grid or control their own content. If a row opens a menu, test where it renders when the row scrolls away. For a related pattern that separates keyboard focus from selection, see our Vue tree-view accessibility guide.

Expected result: focus remains visible, announces the right record, and returns predictably after a reorder or jump. Troubleshooting: if a focused control vanishes on a resize, check whether the window shrank and whether your focus-recovery path scrolls and remounts before calling focus().

6. Handle variable heights, sticky regions, and narrow screens

Action: Ship the fixed-height layout only if rows actually remain fixed at supported text sizes, languages, and widths. Otherwise use measurement-aware virtualization: start with a sensible estimateSize, measure mounted rows, update their offsets, and avoid measuring all records before rendering. TanStack’s Vue virtualization guide documents measureElement and data-index for variable-height rows. Expanded details and wrapping text require special attention because a row can change height after initial mount.

A sticky header must remain in the same horizontal coordinate system as its cells. Pinned columns should remain rendered while other columns slide past them; ensure header, body, and spacer widths agree. On a phone, choose which columns are essential and offer a detail view for secondary fields rather than forcing a tiny spreadsheet. If the grid sits inside an application shell, decide whether the page or the table owns vertical scrolling; two competing scroll regions create keyboard and sticky-header surprises. Our responsive Vue dashboard shell guide covers that broader layout decision.

Expected result: no clipped cell, drifting header, or jumped scroll position when a row expands. Troubleshooting: if a sticky header moves out of alignment, check the scroll owner and the width calculation before adding a second virtualizer.

7. Verify performance and interaction under realistic load

Action: Test a production build with the dataset and cell controls you intend to ship. Compare DOM row count, input response, scroll behavior, and memory before and after virtualization. Record results on a slower device, not only your development laptop. Vue’s profiling guidance suggests the Chrome Performance panel and Vue DevTools; do not present a theoretical row count as a measured speedup.

Run the following checks against first, middle, and last scroll positions:

  1. Inspect mounted rows. The count should scale with viewport height and overscan, not with all records. Try a rapid scroll in both directions and look for blank windows.
  2. Filter to zero, one, and many results. Confirm the scrollbar and announced result count reset correctly, and selection follows IDs after sorting.
  3. Tab through the header, visible checkboxes, and the controls immediately after the table. Verify visible focus, accessible names, row position announcements, and no focus loss during resize or scripted scrolling with the screen readers you support.
  4. Change browser zoom, text size, and viewport width. Check row measurement, sticky headers, horizontal overflow, and keyboard reachability.
  5. With remote data, introduce network delay and errors. Check that old queries cannot append stale pages and that retry does not duplicate records.

Troubleshooting: if the DOM is small but filtering still freezes, profile the sorting/filtering pipeline and the cost of reactive reads. Vue notes that large immutable structures can benefit from shallowRef, but replacing an array is required for updates to trigger. An excessive overscan value or expensive cell components can erase the benefit of a small window. Vue performance guidance

FAQ

Does virtualization replace pagination?

No. Pagination limits which records are fetched or exposed in a page; virtualization limits which of the available records become DOM nodes. We can combine them when each loaded page still contains enough rows to justify windowing. TanStack Vue virtualization guide

Can I keep using a conventional table?

Yes. If the data set and cell content render and update comfortably on the devices you support, ordinary table markup is simpler. Keep pagination or server-side search when the complete data set is too large to load into memory. Add virtualization after observing a rendering bottleneck, not because a row count sounds impressive. Vue performance guidance

What if my rows need multiple heights?

Replace the fixed-height contract with a virtualizer that measures mounted items and updates offsets. Test expanded rows, wrapping, zoom, and jumping to an offscreen record. The same overscan that softens fast scrolling does not fix a wrong measurement model. TanStack Vue virtualization guide

Put the window into your real grid

We now have a fixed-height Vue table whose rendered row count is bounded by the viewport and whose basic search, sort, and selection states are independent of scroll position. The most useful next step is to bring the same row-model and measurement contract to a representative product screen, then test keyboard focus, screen-reader position announcements, and API races against real data. If you already use DOM Studio’s Data Grid examples, start with its virtual-rows demonstration and compare its local-memory behavior with its server-pagination example before choosing how much data your screen should own.

Sources

Recommended Reads