Virtual scrolling helps show only a small part of a big list on screen. This makes the app faster and smoother.
Virtual scrolling for large lists in Vue
<template> <div ref="scrollContainer" @scroll="onScroll" style="height: 300px; overflow-y: auto;"> <div :style="{ height: totalHeight + 'px', position: 'relative' }"> <div v-for="item in visibleItems" :key="item.id" :style="{ position: 'absolute', top: itemPositions[item.index] + 'px', height: itemHeight + 'px', width: '100%' }" > {{ item.text }} </div> </div> </div> </template> <script setup> import { ref, computed, onMounted } from 'vue' const props = defineProps({ items: Array, itemHeight: Number }) const scrollTop = ref(0) const scrollContainer = ref(null) const visibleCount = computed(() => Math.ceil(300 / props.itemHeight) + 1) const totalHeight = computed(() => props.items.length * props.itemHeight) const startIndex = computed(() => Math.floor(scrollTop.value / props.itemHeight)) const visibleItems = computed(() => { return props.items.slice(startIndex.value, startIndex.value + visibleCount.value).map((item, index) => ({ ...item, index: startIndex.value + index })) }) const itemPositions = computed(() => { const positions = {} for (let i = startIndex.value; i < startIndex.value + visibleCount.value; i++) { positions[i] = i * props.itemHeight } return positions }) function onScroll() { scrollTop.value = scrollContainer.value.scrollTop } onMounted(() => { scrollTop.value = 0 }) </script>
The container must have a fixed height and overflow-y set to auto or scroll.
Each item is absolutely positioned inside a tall container to simulate full list height.
<template> <div ref="scrollContainer" @scroll="onScroll" style="height: 300px; overflow-y: auto;"> <div :style="{ height: totalHeight + 'px', position: 'relative' }"> <div v-for="item in visibleItems" :key="item.id" :style="{ position: 'absolute', top: itemPositions[item.index] + 'px', height: itemHeight + 'px', width: '100%' }" > {{ item.text }} </div> </div> </div> </template> <script setup> import { ref, computed } from 'vue' const items = ref([]) // empty list const itemHeight = 30 const scrollTop = ref(0) const scrollContainer = ref(null) const visibleCount = computed(() => Math.ceil(300 / itemHeight) + 1) const totalHeight = computed(() => items.value.length * itemHeight) const startIndex = computed(() => Math.floor(scrollTop.value / itemHeight)) const visibleItems = computed(() => { return items.value.slice(startIndex.value, startIndex.value + visibleCount.value).map((item, index) => ({ ...item, index: startIndex.value + index })) }) const itemPositions = computed(() => { const positions = {} for (let i = startIndex.value; i < startIndex.value + visibleCount.value; i++) { positions[i] = i * itemHeight } return positions }) function onScroll() { scrollTop.value = scrollContainer.value.scrollTop } </script>
<template> <div ref="scrollContainer" @scroll="onScroll" style="height: 300px; overflow-y: auto;"> <div :style="{ height: totalHeight + 'px', position: 'relative' }"> <div v-for="item in visibleItems" :key="item.id" :style="{ position: 'absolute', top: itemPositions[item.index] + 'px', height: itemHeight + 'px', width: '100%' }" > {{ item.text }} </div> </div> </div> </template> <script setup> import { ref, computed } from 'vue' const items = ref([{ id: 1, text: 'Only one item' }]) const itemHeight = 30 const scrollTop = ref(0) const scrollContainer = ref(null) const visibleCount = computed(() => Math.ceil(300 / itemHeight) + 1) const totalHeight = computed(() => items.value.length * itemHeight) const startIndex = computed(() => Math.floor(scrollTop.value / itemHeight)) const visibleItems = computed(() => { return items.value.slice(startIndex.value, startIndex.value + visibleCount.value).map((item, index) => ({ ...item, index: startIndex.value + index })) }) const itemPositions = computed(() => { const positions = {} for (let i = startIndex.value; i < startIndex.value + visibleCount.value; i++) { positions[i] = i * itemHeight } return positions }) function onScroll() { scrollTop.value = scrollContainer.value.scrollTop } </script>
<template> <div ref="scrollContainer" @scroll="onScroll" style="height: 300px; overflow-y: auto;"> <div :style="{ height: totalHeight + 'px', position: 'relative' }"> <div v-for="item in visibleItems" :key="item.id" :style="{ position: 'absolute', top: itemPositions[item.index] + 'px', height: itemHeight + 'px', width: '100%' }" > {{ item.text }} </div> </div> </div> </template> <script setup> import { ref, computed } from 'vue' const items = ref(Array.from({ length: 1000 }, (_, i) => ({ id: i + 1, text: `Item ${i + 1}` }))) const itemHeight = 30 const scrollTop = ref(0) const scrollContainer = ref(null) const visibleCount = computed(() => Math.ceil(300 / itemHeight) + 1) const totalHeight = computed(() => items.value.length * itemHeight) const startIndex = computed(() => Math.floor(scrollTop.value / itemHeight)) const visibleItems = computed(() => { return items.value.slice(startIndex.value, startIndex.value + visibleCount.value).map((item, index) => ({ ...item, index: startIndex.value + index })) }) const itemPositions = computed(() => { const positions = {} for (let i = startIndex.value; i < startIndex.value + visibleCount.value; i++) { positions[i] = i * itemHeight } return positions }) function onScroll() { scrollTop.value = scrollContainer.value.scrollTop } </script>
This Vue component shows a list of 1000 items using virtual scrolling. Only the items visible in the 300px tall container are rendered. Each item is 40px tall and positioned absolutely inside a tall container. Scrolling updates which items are visible.
Keyboard navigation is supported by adding tabindex and role attributes for accessibility.
<template>
<div>
<h2>Virtual Scrolling List</h2>
<div ref="scrollContainer" @scroll="onScroll" style="height: 300px; overflow-y: auto; border: 1px solid #ccc;">
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<div
v-for="item in visibleItems"
:key="item.id"
:style="{ position: 'absolute', top: itemPositions[item.index] + 'px', height: itemHeight + 'px', width: '100%', padding: '0.5rem', boxSizing: 'border-box', borderBottom: '1px solid #eee', backgroundColor: item.index % 2 === 0 ? '#fafafa' : '#fff' }"
tabindex="0"
role="listitem"
aria-label="List item"
>
{{ item.text }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const itemHeight = 40
const items = ref(Array.from({ length: 1000 }, (_, i) => ({ id: i + 1, text: `Item number ${i + 1}` })))
const scrollTop = ref(0)
const scrollContainer = ref(null)
const visibleCount = computed(() => Math.ceil(300 / itemHeight) + 1)
const totalHeight = computed(() => items.value.length * itemHeight)
const startIndex = computed(() => Math.floor(scrollTop.value / itemHeight))
const visibleItems = computed(() => {
return items.value.slice(startIndex.value, startIndex.value + visibleCount.value).map((item, index) => ({
...item,
index: startIndex.value + index
}))
})
const itemPositions = computed(() => {
const positions = {}
for (let i = startIndex.value; i < startIndex.value + visibleCount.value; i++) {
positions[i] = i * itemHeight
}
return positions
})
function onScroll() {
scrollTop.value = scrollContainer.value.scrollTop
}
onMounted(() => {
scrollTop.value = 0
})
</script>Virtual scrolling reduces rendering work by only showing visible items, improving speed.
Time complexity for rendering is O(visibleCount), not total items.
Common mistake: forgetting to set container height or overflow, which breaks scrolling.
Use virtual scrolling when lists are very large; for small lists normal rendering is simpler.
Virtual scrolling shows only visible items in a large list to improve performance.
It uses a fixed height container and absolute positioning inside a tall wrapper.
Scroll events update which items are rendered dynamically.