How to implement virtual scroll in Vue
Virtual scrolling renders only visible items in large lists, dramatically improving performance by reducing DOM nodes from thousands to dozens regardless of total dataset size. As the creator of CoreUI, a widely used open-source UI library, I’ve implemented virtual scrolling in data-intensive dashboards throughout my 12 years of frontend development since 2014. The most reliable approach is using vue-virtual-scroller library which handles viewport calculations, item positioning, and dynamic sizing automatically. This method provides smooth scrolling performance, supports variable item heights, and maintains accessibility without complex manual calculations.
Install vue-virtual-scroller and implement basic virtual scrolling for fixed-height items.
npm install vue-virtual-scroller
<script setup>
import { ref } from 'vue'
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
const items = ref([])
const generateItems = (count) => {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Item ${i + 1}`,
description: `Description for item ${i + 1}`,
price: Math.floor(Math.random() * 1000) + 100
}))
}
items.value = generateItems(10000)
const handleItemClick = (item) => {
console.log('Clicked:', item)
}
</script>
<template>
<div class='virtual-list-container'>
<h2>Virtual Scrolling List (10,000 items)</h2>
<RecycleScroller
class='scroller'
:items='items'
:item-size='80'
key-field='id'
v-slot='{ item }'
>
<div class='list-item' @click='handleItemClick(item)'>
<div class='item-header'>
<h3>{{ item.name }}</h3>
<span class='item-price'>${{ item.price }}</span>
</div>
<p>{{ item.description }}</p>
</div>
</RecycleScroller>
</div>
</template>
<style scoped>
.virtual-list-container {
height: 100vh;
display: flex;
flex-direction: column;
padding: 1rem;
}
.scroller {
flex: 1;
border: 1px solid #ddd;
border-radius: 8px;
overflow-y: auto;
}
.list-item {
padding: 1rem;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background-color 0.2s;
}
.list-item:hover {
background-color: #f5f5f5;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.item-header h3 {
margin: 0;
font-size: 1rem;
}
.item-price {
font-weight: bold;
color: #007bff;
}
.list-item p {
margin: 0;
color: #666;
font-size: 0.875rem;
}
</style>
Implement virtual scrolling with dynamic item heights:
<script setup>
import { ref } from 'vue'
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
const messages = ref([])
const generateMessages = (count) => {
const messageTypes = ['short', 'medium', 'long']
const contents = {
short: 'Short message',
medium: 'This is a medium length message that contains more text than the short one but less than the long message.',
long: 'This is a very long message that contains a lot of text. It demonstrates how virtual scrolling handles variable height items efficiently. The content can vary significantly in length, and the virtual scroller will automatically calculate and adjust the heights as needed. This is particularly useful for chat applications, comment sections, or any interface where content length is unpredictable.'
}
return Array.from({ length: count }, (_, i) => {
const type = messageTypes[Math.floor(Math.random() * messageTypes.length)]
return {
id: i + 1,
author: `User ${Math.floor(Math.random() * 100) + 1}`,
content: contents[type],
timestamp: new Date(Date.now() - Math.random() * 10000000000).toISOString(),
type
}
})
}
messages.value = generateMessages(5000)
</script>
<template>
<div class='chat-container'>
<h2>Virtual Scrolling Chat (5,000 messages)</h2>
<DynamicScroller
:items='messages'
:min-item-size='60'
key-field='id'
class='chat-scroller'
>
<template #default='{ item, index, active }'>
<DynamicScrollerItem
:item='item'
:active='active'
:size-dependencies='[item.content]'
:data-index='index'
>
<div class='message' :class='`message-${item.type}`'>
<div class='message-header'>
<strong>{{ item.author }}</strong>
<span class='timestamp'>
{{ new Date(item.timestamp).toLocaleTimeString() }}
</span>
</div>
<div class='message-content'>
{{ item.content }}
</div>
</div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</div>
</template>
<style scoped>
.chat-container {
height: 100vh;
display: flex;
flex-direction: column;
padding: 1rem;
}
.chat-scroller {
flex: 1;
border: 1px solid #ddd;
border-radius: 8px;
overflow-y: auto;
background: #f9f9f9;
}
.message {
margin: 0.5rem;
padding: 1rem;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.message-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.timestamp {
color: #666;
}
.message-content {
line-height: 1.5;
}
.message-short {
border-left: 3px solid #28a745;
}
.message-medium {
border-left: 3px solid #ffc107;
}
.message-long {
border-left: 3px solid #dc3545;
}
</style>
Custom virtual scroll implementation for learning purposes:
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const items = ref(Array.from({ length: 10000 }, (_, i) => ({
id: i,
text: `Item ${i}`
})))
const containerRef = ref(null)
const scrollTop = ref(0)
const containerHeight = ref(600)
const itemHeight = 50
const bufferSize = 5
const visibleStart = computed(() => {
return Math.max(0, Math.floor(scrollTop.value / itemHeight) - bufferSize)
})
const visibleEnd = computed(() => {
const end = Math.ceil((scrollTop.value + containerHeight.value) / itemHeight) + bufferSize
return Math.min(items.value.length, end)
})
const visibleItems = computed(() => {
return items.value.slice(visibleStart.value, visibleEnd.value)
})
const totalHeight = computed(() => {
return items.value.length * itemHeight
})
const offsetY = computed(() => {
return visibleStart.value * itemHeight
})
const handleScroll = (event) => {
scrollTop.value = event.target.scrollTop
}
onMounted(() => {
if (containerRef.value) {
containerHeight.value = containerRef.value.clientHeight
}
})
</script>
<template>
<div
ref='containerRef'
class='virtual-scroll-container'
@scroll='handleScroll'
>
<div class='virtual-scroll-spacer' :style='{ height: `${totalHeight}px` }'>
<div class='virtual-scroll-content' :style='{ transform: `translateY(${offsetY}px)` }'>
<div
v-for='item in visibleItems'
:key='item.id'
class='virtual-scroll-item'
:style='{ height: `${itemHeight}px` }'
>
{{ item.text }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.virtual-scroll-container {
height: 600px;
overflow-y: auto;
border: 1px solid #ddd;
position: relative;
}
.virtual-scroll-spacer {
position: relative;
}
.virtual-scroll-content {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.virtual-scroll-item {
display: flex;
align-items: center;
padding: 0 1rem;
border-bottom: 1px solid #eee;
}
</style>
Here the RecycleScroller component handles fixed-height items with automatic DOM recycling for optimal performance. The item-size prop defines pixel height for each item, enabling accurate scroll calculations. The DynamicScroller supports variable-height items by measuring actual rendered heights dynamically. The min-item-size provides initial estimate before actual measurement for smoother initial render. The size-dependencies array triggers height recalculation when specified item properties change. The custom implementation demonstrates viewport calculation using scrollTop position and container height. The bufferSize adds extra items above and below viewport to prevent flickering during fast scrolling. The transform translateY positions visible items at correct scroll offset without rendering hidden items.
Best Practice Note:
This is the virtual scrolling approach we use in CoreUI for Vue when handling data tables with tens of thousands of rows for instant rendering. Use RecycleScroller for uniform item heights to maximize performance, implement DynamicScroller only when item heights truly vary to avoid unnecessary measurement overhead, and add scroll position restoration when navigating back to lists to maintain user context and improve navigation experience.



