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 this example is built

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 - the fastest form, valid only when every row has that height. 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.

<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