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.
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.
<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:
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.: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.
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.
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.
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.
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.
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]
}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.
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.
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.
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.
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.
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.aria-setsize and aria-posinset to each item yourself.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.
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.