How to implement virtual scrolling in JavaScript
Virtual scrolling is essential for rendering large lists without performance degradation by only rendering items currently visible in the viewport. As the creator of CoreUI with over 25 years of JavaScript experience since 2000, I’ve implemented virtual scrolling for data tables, feeds, and lists with millions of items. The core technique calculates which items are visible based on scroll position, renders only those items, and adjusts spacing to maintain correct scroll height. This provides smooth scrolling even with massive datasets.
Implement basic virtual scrolling by rendering only visible items.
class VirtualScroll {
constructor(container, items, itemHeight) {
this.container = container
this.items = items
this.itemHeight = itemHeight
this.visibleItems = Math.ceil(container.clientHeight / itemHeight)
this.totalHeight = items.length * itemHeight
this.setupContainer()
this.render()
this.container.addEventListener('scroll', () => this.render())
}
setupContainer() {
this.container.style.height = '500px'
this.container.style.overflow = 'auto'
this.container.style.position = 'relative'
this.content = document.createElement('div')
this.content.style.height = `${this.totalHeight}px`
this.container.appendChild(this.content)
}
render() {
const scrollTop = this.container.scrollTop
const startIndex = Math.floor(scrollTop / this.itemHeight)
const endIndex = startIndex + this.visibleItems + 1
this.content.innerHTML = ''
for (let i = startIndex; i <= endIndex && i < this.items.length; i++) {
const item = document.createElement('div')
item.style.position = 'absolute'
item.style.top = `${i * this.itemHeight}px`
item.style.height = `${this.itemHeight}px`
item.textContent = this.items[i]
this.content.appendChild(item)
}
}
}
// Usage
const container = document.getElementById('scroll-container')
const items = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`)
new VirtualScroll(container, items, 50)
The container has fixed height with overflow scroll. The content div represents the total scrollable area. Only visible items plus a buffer are rendered. Items are absolutely positioned at their correct vertical offsets. The scroll handler re-renders on scroll.
Adding Variable Item Heights
Handle lists where items have different heights.
class VariableHeightVirtualScroll {
constructor(container, items, getItemHeight) {
this.container = container
this.items = items
this.getItemHeight = getItemHeight
this.itemOffsets = this.calculateOffsets()
this.totalHeight = this.itemOffsets[this.itemOffsets.length - 1]
this.setupContainer()
this.render()
this.container.addEventListener('scroll', () => this.render())
}
calculateOffsets() {
const offsets = [0]
for (let i = 0; i < this.items.length; i++) {
offsets.push(offsets[i] + this.getItemHeight(this.items[i], i))
}
return offsets
}
setupContainer() {
this.container.style.height = '500px'
this.container.style.overflow = 'auto'
this.container.style.position = 'relative'
this.content = document.createElement('div')
this.content.style.height = `${this.totalHeight}px`
this.container.appendChild(this.content)
}
findStartIndex(scrollTop) {
let low = 0
let high = this.items.length - 1
while (low <= high) {
const mid = Math.floor((low + high) / 2)
if (this.itemOffsets[mid] <= scrollTop) {
low = mid + 1
} else {
high = mid - 1
}
}
return Math.max(0, high)
}
render() {
const scrollTop = this.container.scrollTop
const viewportHeight = this.container.clientHeight
const startIndex = this.findStartIndex(scrollTop)
this.content.innerHTML = ''
let currentOffset = this.itemOffsets[startIndex]
let i = startIndex
while (currentOffset < scrollTop + viewportHeight && i < this.items.length) {
const itemHeight = this.getItemHeight(this.items[i], i)
const item = document.createElement('div')
item.style.position = 'absolute'
item.style.top = `${this.itemOffsets[i]}px`
item.style.height = `${itemHeight}px`
item.textContent = this.items[i]
this.content.appendChild(item)
currentOffset += itemHeight
i++
}
}
}
// Usage with variable heights
const items = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`)
const getHeight = (item, index) => 50 + (index % 3) * 25 // Heights: 50, 75, 100
new VariableHeightVirtualScroll(
document.getElementById('container'),
items,
getHeight
)
The calculateOffsets computes the cumulative Y position of each item. Binary search finds the first visible item efficiently. Items are rendered while their offsets are within the viewport. This handles variable-height content correctly.
Implementing Smooth Scrolling with Buffer
Add buffer items above and below viewport for smoother scrolling.
class BufferedVirtualScroll {
constructor(container, items, itemHeight, bufferSize = 5) {
this.container = container
this.items = items
this.itemHeight = itemHeight
this.bufferSize = bufferSize
this.totalHeight = items.length * itemHeight
this.setupContainer()
this.render()
this.container.addEventListener('scroll', () => this.handleScroll())
this.ticking = false
}
setupContainer() {
this.container.style.height = '500px'
this.container.style.overflow = 'auto'
this.container.style.position = 'relative'
this.content = document.createElement('div')
this.content.style.height = `${this.totalHeight}px`
this.content.style.position = 'relative'
this.container.appendChild(this.content)
}
handleScroll() {
if (!this.ticking) {
requestAnimationFrame(() => {
this.render()
this.ticking = false
})
this.ticking = true
}
}
render() {
const scrollTop = this.container.scrollTop
const viewportHeight = this.container.clientHeight
const visibleCount = Math.ceil(viewportHeight / this.itemHeight)
const startIndex = Math.max(0,
Math.floor(scrollTop / this.itemHeight) - this.bufferSize
)
const endIndex = Math.min(
this.items.length - 1,
startIndex + visibleCount + this.bufferSize * 2
)
this.content.innerHTML = ''
const fragment = document.createDocumentFragment()
for (let i = startIndex; i <= endIndex; i++) {
const item = document.createElement('div')
item.style.position = 'absolute'
item.style.top = `${i * this.itemHeight}px`
item.style.height = `${this.itemHeight}px`
item.style.width = '100%'
item.textContent = this.items[i]
fragment.appendChild(item)
}
this.content.appendChild(fragment)
}
}
The buffer adds extra items above and below the viewport. This prevents blank spaces during fast scrolling. The requestAnimationFrame debounces scroll events for smooth rendering. The DocumentFragment batches DOM operations for better performance.
Adding Horizontal Virtual Scrolling
Implement virtual scrolling for wide content.
class HorizontalVirtualScroll {
constructor(container, items, itemWidth) {
this.container = container
this.items = items
this.itemWidth = itemWidth
this.totalWidth = items.length * itemWidth
this.setupContainer()
this.render()
this.container.addEventListener('scroll', () => this.render())
}
setupContainer() {
this.container.style.width = '800px'
this.container.style.height = '100px'
this.container.style.overflowX = 'auto'
this.container.style.overflowY = 'hidden'
this.container.style.position = 'relative'
this.content = document.createElement('div')
this.content.style.width = `${this.totalWidth}px`
this.content.style.height = '100%'
this.content.style.position = 'relative'
this.container.appendChild(this.content)
}
render() {
const scrollLeft = this.container.scrollLeft
const viewportWidth = this.container.clientWidth
const visibleCount = Math.ceil(viewportWidth / this.itemWidth)
const startIndex = Math.floor(scrollLeft / this.itemWidth)
const endIndex = Math.min(
this.items.length - 1,
startIndex + visibleCount + 1
)
this.content.innerHTML = ''
for (let i = startIndex; i <= endIndex; i++) {
const item = document.createElement('div')
item.style.position = 'absolute'
item.style.left = `${i * this.itemWidth}px`
item.style.width = `${this.itemWidth}px`
item.style.height = '100%'
item.style.display = 'inline-block'
item.textContent = this.items[i]
this.content.appendChild(item)
}
}
}
Horizontal scrolling uses the same principle but with scrollLeft and left positioning. The container has overflowX: auto. Items are positioned horizontally using left instead of top. This works for wide tables or horizontal carousels.
Implementing Bi-Directional Virtual Scrolling
Handle both vertical and horizontal scrolling simultaneously.
class GridVirtualScroll {
constructor(container, rows, cols, cellWidth, cellHeight) {
this.container = container
this.rows = rows
this.cols = cols
this.cellWidth = cellWidth
this.cellHeight = cellHeight
this.totalWidth = cols * cellWidth
this.totalHeight = rows * cellHeight
this.setupContainer()
this.render()
this.container.addEventListener('scroll', () => this.render())
}
setupContainer() {
this.container.style.width = '800px'
this.container.style.height = '600px'
this.container.style.overflow = 'auto'
this.container.style.position = 'relative'
this.content = document.createElement('div')
this.content.style.width = `${this.totalWidth}px`
this.content.style.height = `${this.totalHeight}px`
this.content.style.position = 'relative'
this.container.appendChild(this.content)
}
render() {
const scrollTop = this.container.scrollTop
const scrollLeft = this.container.scrollLeft
const viewportWidth = this.container.clientWidth
const viewportHeight = this.container.clientHeight
const startRow = Math.floor(scrollTop / this.cellHeight)
const endRow = Math.min(
this.rows - 1,
Math.ceil((scrollTop + viewportHeight) / this.cellHeight)
)
const startCol = Math.floor(scrollLeft / this.cellWidth)
const endCol = Math.min(
this.cols - 1,
Math.ceil((scrollLeft + viewportWidth) / this.cellWidth)
)
this.content.innerHTML = ''
for (let row = startRow; row <= endRow; row++) {
for (let col = startCol; col <= endCol; col++) {
const cell = document.createElement('div')
cell.style.position = 'absolute'
cell.style.top = `${row * this.cellHeight}px`
cell.style.left = `${col * this.cellWidth}px`
cell.style.width = `${this.cellWidth}px`
cell.style.height = `${this.cellHeight}px`
cell.style.border = '1px solid #ddd'
cell.textContent = `R${row}C${col}`
this.content.appendChild(cell)
}
}
}
}
// Usage for grid/spreadsheet
new GridVirtualScroll(
document.getElementById('container'),
10000, // rows
1000, // columns
100, // cell width
50 // cell height
)
Grid scrolling calculates visible ranges for both dimensions. Nested loops render cells in the visible grid area. This enables spreadsheet-like interfaces with millions of cells while maintaining smooth performance.
Best Practice Note
This is the same virtual scrolling technique we use in CoreUI data tables and infinite scroll components to maintain 60fps performance with large datasets. Always measure performance before implementing virtual scrolling - it adds complexity and may not be needed for lists under 1000 items. Use IntersectionObserver API as an alternative for simpler infinite scroll scenarios. For production applications, consider libraries like react-window or vue-virtual-scroller which handle edge cases like dynamic heights and keyboard navigation. Test scroll performance on low-end devices to ensure smooth experience. Virtual scrolling is essential for data-heavy interfaces but adds complexity - use it when rendering performance becomes a measurable problem.



