Vertical Dynamic

Vertical scrolling with variable item heights
Vertical scrolling with variable item heights for 1,000 items. Automatically measures item sizes using ResizeObserver. Even items are 50px, odd items are 100px.

How to build a feature like this

To virtualize a vertical list whose rows differ in height, uniform-size arithmetic stops being sufficient: each row's offset depends on the heights of all the rows before it. Dynamic mode solves this by measuring each rendered row with a ResizeObserver so exact offsets and the total scroll height emerge from measured sizes rather than a formula. The real cost is that dynamic lists are heavier than uniform ones - they start from an estimate that corrects itself as rows mount, and only mounted rows can be measured.

1. Choose the size strategy that matches your data

item-size accepts four forms. A positive number means uniform heights and O(1) arithmetic - fastest, but only valid when every row really is that tall. An array describes a repeating pattern (e.g. [50, 100]) and a function (item, index) => number a height known up front that varies per row; both let the engine lay out far-off rows from the declared pattern or function without mounting them - avoiding measurement, at the cost of per-item storage rather than a uniform number's O(1). Pass 0, null, or undefined - or omit the prop - to enable dynamic mode, where heights are measured from the DOM. Use dynamic only when heights are content-driven and unknowable until rendered (wrapping text, images, live data); if you can compute them, an array or function skips the measuring cost.

2. Render real rows at a definite, stable height

In dynamic mode the engine measures whatever box each rendered row actually has. Carry the intended size on your data and express it on the row - a min-block-size or a real height - so the box cannot collapse, and reserve space for media (width/height or aspect-ratio) so late content growth does not push neighbors down. The row's measured box (its border-box size, padding included) drives the layout, and a live resize is caught by the same observer and re-lays the list.

The examples also draw the built-in virtual scrollbar (boolean virtual-scrollbar) on the list. Besides consistent cross-browser styling it is a performance improvement: the overlay bar is driven by the engine's own scroll math, so its rendering cost stays flat no matter how long the list grows.

<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';

// Each row knows its own height; no `item-size` prop is passed, so heights
// are treated as dynamic and measured from the rendered DOM.
const items = Array.from({ length: 1000 }, (_, i) => ({
  id: i,
  height: i % 2 === 0 ? 50 : 100,
}));
</script>

<template>
  <VirtualScroll
    virtual-scrollbar
    class="v-dyn"
    :items="items"
    :default-item-size="80"
    aria-label="Dynamic height list"
  >
    <template #item="{ item }">
      <!-- Omit item-size → ResizeObserver reports this row's block size. -->
      <div class="row" :style="{ minBlockSize: `${ item.height }px` }">
        #{{ item.id }} - {{ item.height }}px tall
      </div>
    </template>
  </VirtualScroll>
</template>

<style scoped>
.v-dyn {
  block-size: 480px; /* fixed-height scroll viewport */
  border: 1px solid oklch(50% 0 0 / 0.2);
}
.row {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  padding: 0 1rem;
  border-bottom: 1px solid oklch(50% 0 0 / 0.1);
}
</style>

3. Follow the estimate-then-measure pipeline

Only rows that are mounted can be measured, so any row that has never entered the viewport keeps the fallback default-item-size (default 40). That estimate drives the initial range and the total scroll height until the row renders, at which point the observed height replaces it. Expect the first layout - and any deep scrollToIndex into unmeasured regions - to settle over a frame or two as measurements land. Set default-item-size near your average row height to shrink the initial error; the buffer-before/buffer-after overscan (default 5) keeps rows mounted just off-screen so this measuring happens before they scroll into view.

#0
Dynamic Item 0 (Height: 50px)
#1
Dynamic Item 1 (Height: 100px)
#2
Dynamic Item 2 (Height: 50px)
#3
Dynamic Item 3 (Height: 100px)
#4
Dynamic Item 4 (Height: 50px)
  • Scroll Status
  • Direction
    vertical
  • Current Item #
    -
  • Rendered Range #
    0:0
  • DOM Items #
  • Total Size (px)
    0h
  • Viewport Size (px)
    0h
  • Scroll Offset (px)
    0y
  • Controls