Black Friday: 50% off all products for the first 50 customers. Only 10 left. Use code
40/50 claimed
See pricing →
November 10, 2026

Quasar Virtual Scroll: Render Huge Lists Fast

Quasar virtual scroll with QVirtualScroll and QTable: render 10,000-row lists, and fix the item size, height and reactivity mistakes that make it jump.
Quasar
Vue
Performance
Lists

Quasar virtual scroll renders only the rows the user can see, plus a few around them, instead of every row in the array. A list of 10,000 items puts a few dozen elements in the DOM, and scrolling stays smooth. Quasar has it in two places: the q-virtual-scroll component for any list, and the virtual-scroll prop on q-table. Both are easy to switch on and easy to get subtly wrong, so this post covers the setup and then the sizing mistakes that make a virtual list jump. Everything matches Quasar 2.35.2.

When you need Quasar virtual scroll

Virtual scroll pays off when the list is long and every row is real DOM: a log viewer, a chat history, a contact list, a table with thousands of rows on one page. For a list of 50 or 100 rows, plain v-for is simpler and fast enough. And if users page through the data anyway, server-side pagination keeps both the DOM and the payload small.

It sits alongside the other rendering advice in the Quasar performance checklist: render less, and render it later.

A basic QVirtualScroll list

<script setup lang="ts">
interface Contact {
  id: number
  name: string
  email: string
}

const contacts: Contact[] = Array.from({ length: 10_000 }, (_, index) => ({
  id: index + 1,
  name: `Contact ${index + 1}`,
  email: `contact${index + 1}@example.com`,
}))
</script>

<template>
  <q-virtual-scroll
    style="max-height: 480px"
    :items="contacts"
    :virtual-scroll-item-size="64"
    separator
    bordered
    v-slot="{ item }"
  >
    <q-item :key="item.id">
      <q-item-section>
        <q-item-label>{{ item.name }}</q-item-label>
        <q-item-label caption>{{ item.email }}</q-item-label>
      </q-item-section>
    </q-item>
  </q-virtual-scroll>
</template>

The parts that matter:

  • A height limit. max-height makes the component its own scroll container. Without one, there's nothing to scroll, so every item renders.
  • :items is a plain array, not a ref. The Quasar docs recommend this: Vue then skips making 10,000 objects reactive.
  • A :key on the item root, using a stable id rather than the index.
  • virtual-scroll-item-size close to the real row height. More on that below.

By default q-virtual-scroll renders a q-list, so separator, bordered, dense and padding work like they do on a list. The default slot gives you item and index.

Why a virtual list jumps, and how to fix it

The item size is far from the real height

QVirtualScroll can't measure rows it hasn't rendered, so it estimates them with virtual-scroll-item-size. It measures each row once it's on screen, but until then the scrollbar works from the estimate. A wrong estimate shows up as a scrollbar thumb that drifts away from the pointer while you drag it, or a list that lands a row or two off on mobile.

The default is 24 (pixels). A standard q-item has a minimum height of 48px, a dense one 32px, and a two-line item like the one above is taller still. So for q-item rows, always set the size. Measure one row in devtools and use that, or the smallest row height if they vary. q-table is already set up for this: in virtual scroll mode it estimates 48px per row, or 28px with dense.

Rows change height after they render

An image without a fixed height, or text that wraps differently on a phone, changes the row height after Quasar has measured it. Give images a fixed height (or an aspect-ratio box) and limit text with lines="1" on q-item-label, so each row keeps its height.

One item renders more than one element

If your slot renders two root elements for one item (a row and a detail row under it), QVirtualScroll counts them as two items, and the scroll math is off. Add the q-virtual-scroll--with-prev class to every element after the first, so it's measured together with the previous one. For an element that shouldn't count at all, like a divider, use q-virtual-scroll--skip.

A sticky header isn't accounted for

With a sticky header or footer, tell the component how tall it is with virtual-scroll-sticky-size-start and virtual-scroll-sticky-size-end. Without them, scrolling to an item lands it under the header.

The array is deeply reactive

ref(bigArray) wraps every object in a proxy, which costs time and memory before anything renders. If the list never changes, use a plain array. If it does change, use shallowRef and replace the array rather than mutating it:

import { shallowRef } from 'vue'

const messages = shallowRef<Message[]>([])

function appendMessages(more: Message[]) {
  messages.value = [...messages.value, ...more]
}

Using the page or a QScrollArea as the scroller

By default the component scrolls itself. To make the whole page scroll, with a header above the list that scrolls away, set scroll-target="body". To scroll inside a q-scroll-area, point it at the inner scroll element:

<q-scroll-area id="contact-scroll" style="height: 480px">
  <q-virtual-scroll
    scroll-target="#contact-scroll > .scroll"
    :items="contacts"
    :virtual-scroll-item-size="64"
    v-slot="{ item }"
  >
    <q-item :key="item.id">...</q-item>
  </q-virtual-scroll>
</q-scroll-area>

The Quasar docs set two rules for a custom target: it must exist, and it must be able to overflow, meaning it has a height limit and a scrolling overflow. If it can't overflow, you're back to rendering the whole list. To pass a template ref instead of a selector, set it after mount.

Jumping to an item

The component exposes scrollTo(index, edge). Use it for "jump to unread" or a search result:

import { useTemplateRef } from 'vue'
import type { QVirtualScroll } from 'quasar'

const list = useTemplateRef<QVirtualScroll>('list')

function showContact(index: number) {
  list.value?.scrollTo(index, 'center-force')
}

edge is 'start', 'center' or 'end'. Without the -force suffix, Quasar only aligns the item when it isn't already visible. The @virtual-scroll event reports index, from, to and direction as the user scrolls, and refresh() recalculates the list after you append items.

QTable virtual scroll

q-table builds on the same code. Turn it on with virtual-scroll, give the table a fixed height, and show all rows in one page, otherwise there's nothing long enough to virtualize:

<q-table
  style="height: 520px"
  flat
  bordered
  virtual-scroll
  row-key="id"
  :rows="rows"
  :columns="columns"
  v-model:pagination="pagination"
  :rows-per-page-options="[0]"
/>
const pagination = ref({ rowsPerPage: 0 })

rowsPerPage: 0 means all rows, and :rows-per-page-options="[0]" keeps the user from switching back to pages. Virtual scroll and pagination can also work together: the virtual list then holds the current page, for example 1,000 rows at a time.

Sorting and filtering still run over the whole rows array. The Quasar QTable tutorial covers those, and they work the same in virtual mode.

Loading more rows as the user scrolls

Listen to @virtual-scroll. When to reaches the last loaded row, fetch the next batch, then call refresh() on the reference the event gives you:

import { nextTick } from 'vue'

async function onScroll({ to, ref: table }: { to: number; ref: { refresh: () => void } }) {
  if (loading.value || !hasMore.value || to !== rows.value.length - 1) return

  loading.value = true
  rows.value = [...rows.value, ...(await fetchPage(nextPage.value++))]
  await nextTick()
  table.refresh()
  loading.value = false
}

Bind it with @virtual-scroll="onScroll" and :loading="loading". fetchPage and hasMore are your own API call and end-of-data check.

Sticky columns with virtual scroll

The Quasar docs flag one trap here. Virtual scroll fakes the full height of the table with two hidden filler rows, q-virtual-scroll__padding. A rule like td:first-child { position: sticky } matches their cells too, and dragging the scrollbar then runs away to the end of the table. Scope the rule to the data rows: tbody:not(.q-virtual-scroll__padding) td:first-child.

SSR and accessibility notes

  • SSR. The server can't measure the viewport, so it renders the first virtual-scroll-slice-size items (10 by default), and the client re-slices after hydration. Your item template runs on the server too, so keep it free of browser-only code.
  • Screen readers. Rows outside the rendered slice aren't in the DOM, so assistive technology sees only the visible window. When the total matters, add aria-setsize and aria-posinset to each item yourself.
  • Browser limits. Browsers cap the height of a scroll container (Chromium at about 16.7 million pixels), so even a virtual list has a ceiling. At 48px per row, that's around 350,000 rows.

Common questions about Quasar virtual scroll

Why does my virtual list render every item? The container can't overflow. Give q-virtual-scroll (or your scroll-target) a height or max-height.

Can I load the items asynchronously with items-fn? No. items-fn must be synchronous. Load the data first, or render a component per item that fetches its own content.

Does QSelect use virtual scroll? Yes. Its options menu is virtualized, so long option lists are fast without extra setup.

Should I use virtual scroll or infinite scroll? Virtual scroll limits how much is in the DOM. Infinite scroll limits how much you load. For long feeds, combine them as in the QTable example.

Next step

A virtual list is only as good as its row. The Stacked Lists Block has 16 list designs built from q-list and q-item, with avatars, badges, chips and actions, and two of them are free. None of them is virtualized as shipped. Copy a design, move its q-item into the q-virtual-scroll slot, and set the item size to the row's real height.