Vertical Dynamic Body

Native window scrolling with variable item heights
This example uses the main browser window for scrolling 1,000 dynamic items. Sizes are automatically detected via ResizeObserver.

How to build a feature like this

This pattern combines two independent mechanisms. First, the list is not scrolled by its own host - the browser window is the scroller, which the container prop declares. Second, rows have variable heights, so item-size is left unset and each rendered row's height is measured with a ResizeObserver rather than computed. Understand them separately and the combination is straightforward; conflating them is the usual source of surprise.

1. Declare the window as the real scroll container

container defaults to the component's own host, which gives a self-contained internal scroller. To make the page itself scroll instead, pass the window (or body) element. Because window exists only on the client, hold it in a ref assigned in onMounted - that is also SSR-safe. Do not cap the list height: the host grows with the full virtual content so the document becomes tall enough for the window to scroll, and the header/footer slots scroll past as ordinary in-flow content.

2. Leave item-size unset and give rows a definite height

Passing no item-size (or 0/null) puts the axis in dynamic mode: heights come from measuring each mounted row. Carry the intended height on your data and apply it to the row (a min-block-size keeps it from collapsing) so the observer has a stable box to report. Only mounted rows can be measured; rows that have not yet entered the viewport keep the default-item-size estimate (default 40), which is replaced as they render. In window mode you feel this as the document height subtly adjusting while the first rows settle.

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

const scrollContainer = ref<Window | null>(null);
onMounted(() => {
  scrollContainer.value = window; // client only - SSR-safe
});

const items = Array.from({ length: 1000 }, (_, i) => ({
  id: i,
  height: i % 2 === 0 ? 50 : 100,
}));
</script>

<template>
  <VirtualScroll
    class="body-list"
    :items="items"
    :container="scrollContainer"
    aria-label="Body-scrolling dynamic list"
  >
    <template #header>
      <p class="page-header">Header that scrolls with the page</p>
    </template>
    <template #item="{ item }">
      <!-- No item-size → heights are measured off the page flow. -->
      <div class="row" :style="{ minBlockSize: `${ item.height }px` }">Item {{ item.id }}</div>
    </template>
    <template #footer>
      <p class="page-footer">Page footer</p>
    </template>
  </VirtualScroll>
</template>

<style scoped>
.body-list {
  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. Expect a settle as estimates become measurements

Dynamic rows make the layout correct in stages: unmeasured rows are laid out at default-item-size, and each measurement replaces the estimate, moving later rows and the total. Because the window scrolls natively, that correction surfaces as the page height changing during the first pass or after a deep scrollToIndex. Set default-item-size near your average row height to shorten it, and keep the buffer (default 5) so rows measure just off-screen, before they are needed.

4. Respect the window-mode size ceiling

When the scroll container is the browser window, the virtual scrollbar and coordinate scaling are disabled and native scrolling is used. That removes the safety net that rescales oversized content, so keep the total height under the browser's ~10M px DOM scroll limit. If a window-scrolled list needs to exceed that, move it into an element scroller instead, where scaling is available.

Scrollable Header

This header and fixed height items scroll with the page

#0
Body Scroll Dynamic Item 0 (Height: 50px)
#1
Body Scroll Dynamic Item 1 (Height: 100px)
#2
Body Scroll Dynamic Item 2 (Height: 50px)
#3
Body Scroll Dynamic Item 3 (Height: 100px)
#4
Body Scroll 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