Vertical Fixed Body

Native window scrolling with uniform item heights
This example uses the main browser window for scrolling 1,000 items instead of a nested container. Item height is fixed at 90px.

How to build a feature like this

Usually <VirtualScroll> renders its own scrollable host and virtualizes inside it. Sometimes you want the list to live directly in the page so that the browser window itself scrolls - items pass under your normal page chrome, with headers and footers scrolling past naturally. The mechanism that enables this is the container prop: it tells the engine which element actually scrolls, and it may be another HTMLElement or the browser window/body. The rows are still virtualized (only the visible window is mounted), and uniform heights keep their placement O(1) arithmetic.

1. Decide who scrolls: the host or another element

container defaults to the component's own host element, which gives you a self-contained, internal scroller. Pass a different element or the window/body to virtualize against whatever actually scrolls - handy when the list must flow with the page rather than sit in a fixed-height box. Because the scroller is now outside the component, the component must be told about it explicitly; this is a case where the library's default ("the host scrolls") is not what you want.

2. Wire the window client-side and let the host grow

window only exists on the client, so hold it in a ref and assign it in onMounted - this keeps server-side rendering safe. Then, unlike the element-scroller demos, do not cap the list's height: the host must grow with the full virtual content so the document becomes tall enough for the window to scroll. The #header / #footer slots are ordinary in-flow content that scrolls past rather than sticking.

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

// `window` exists only on the client - assign after mount (SSR-safe).
const scrollContainer = ref<Window | null>(null);
onMounted(() => {
  scrollContainer.value = window;
});

// Uniform heights keep positioning arithmetic; the array still carries data.
const items = Array.from({ length: 1000 }, (_, i) => ({
  id: i,
  label: `Body item ${ i }`,
}));
</script>

<template>
  <!-- container points the engine at the element that actually scrolls. -->
  <VirtualScroll
    class="body-list"
    :items="items"
    :item-size="90"
    :container="scrollContainer"
    aria-label="Body-scrolling list"
  >
    <template #header>
      <p class="page-header">Header that scrolls with the page</p>
    </template>
    <template #item="{ item, index }">
      <div class="row">
        <span>#{{ index }}</span>
        {{ item.label }}
      </div>
    </template>
    <template #footer>
      <p class="page-footer">Page footer</p>
    </template>
  </VirtualScroll>
</template>

<style scoped>
/* No fixed height: the document is the scroller, so the host must grow with
   the full virtual content to make the page tall enough to scroll. */
.body-list {
  border: 1px solid oklch(50% 0 0 / 0.2);
}
.row {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  block-size: 90px; /* must equal item-size */
  padding: 0 1rem;
  border-bottom: 1px solid oklch(50% 0 0 / 0.1);
}
</style>

3. Uniform rows are O(1) - and may be index-only

With a numeric item-size the engine places rows arithmetically and never reads payloads outside the rendered window, so uniform lists can equally be data-less: a sparse new Array(count) works and you render each row from its index (the item slot prop is then undefined). Reach for real item objects when rows carry content; use the sparse form when a row is fully described by its position. In both cases item-size is a contract and must equal the rendered row height.

4. Account for window-mode behavior

Two conveniences drop away when the browser itself scrolls: the virtual scrollbar is disabled for a window/body container and coordinate scaling for the window, so native scrolling is used. The practical consequence is a size ceiling - content must stay under the browser's ~10M px DOM scroll limit, because nothing rescales it. Reserve element-scroller mode (where scaling and the themed scrollbar are available) for lists large enough to need them. The buffers still overscan rows and the virtualization logic is otherwise identical.

Scrollable Header

This header and fixed height items scroll with the page

#0
Item 0
Body Scroll Fixed Item 0
#1
Item 1
Body Scroll Fixed Item 1
#2
Item 2
Body Scroll Fixed Item 2
#3
Item 3
Body Scroll Fixed Item 3
#4
Item 4
Body Scroll Fixed Item 4
  • 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