Vertical Dynamic

Vertical scrolling with variable item heights
1,000 items of varying height, each measured with ResizeObserver as it mounts. Even items are 50px, odd items 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. 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 }">
      &lt;!-- 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