SSR Support
How to build a feature like this
A virtualized list normally mounts only a small, viewport-sized window - and when the page is server-rendered there is no browser layout yet, so a plain render would emit an empty scroll box that crawlers and no-JS clients can never read. To virtualize for the server you instead pick which rows (and, in a grid, columns) should already exist as real static HTML, describe them with the ssrRange prop, and let the component scroll to that range once the client hydrates. Because the same range drives the server output and the very first client render, Vue hydrates against an identical tree; afterwards the component switches to its usual recycled, absolutely-positioned window. Two consequences shape the code: the pre-rendered slice must be described by deterministic numeric sizes (nothing can be measured on the server), and the items plus range must be identical on both sides of hydration.
1. Feed the list from a data source both renders share
Pass your rows to :items and add :ssr-range - an object of the shape { start, end, colStart?, colEnd? } where start/end bound the rows and colStart/colEnd bound columns in grid mode; end and colEnd are exclusive. Vue hydration matches the first client render against the server HTML, so load items and ssrRange through a mechanism that runs identically on both sides - your framework's data loader (for example a Vike +data.ts) or any SSR-capable store - never inside a client-only onMounted effect. The range is your chosen first paint; it does not have to begin at index 0, because the component scrolls to it on hydration. Keep it small enough to be a sensible initial paint - the client virtualizes everything around it.
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';
// Load items + ssrRange from a source that runs on the server and again on
// the first client render (framework data loader / SSR-capable store), so
// the initial HTML and the tree Vue hydrates are identical.
const items = Array.from({ length: 10_000 }, (_, i) => ({ id: i, label: `Row ${ i }` }));
// Pre-render rows 200..214 as static HTML. end is EXCLUSIVE.
const ssrRange = { start: 200, end: 215 };
</script>
<template>
<VirtualScroll
virtual-scrollbar
class="list"
:items="items"
:item-size="48"
:ssr-range="ssrRange"
>
<!-- Mainstream: render the row payload from `item`. -->
<template #item="{ item }">
<div class="row">{{ item.label }}</div>
</template>
</VirtualScroll>
</template>
<style scoped>
/* The client host needs a definite height so it can scroll. */
.list {
height: 480px;
border: 1px solid oklch(50% 0 0 / 0.2);
}
/* Each row wrapper is exactly item-size (48px) tall; the inner div fills it. */
.row {
box-sizing: border-box;
display: flex;
align-items: center;
height: 100%;
padding-inline: 1rem;
}
</style>2. Render rows from your data - and when to skip payloads
The #item slot holds your row markup and re-renders every time a row enters the window. The mainstream form is real objects: the slot destructures { item } and renders the payload's fields, keeping the content in one source of truth. The specialized form applies when a row is fully described by its position - numbering, separators, ticks, or content you address by index: read only { index } and pass a length-only placeholder array instead of materializing a per-row object. Prefer the real-object form unless your rows are purely positional, and render idempotently either way - a recycled row can mount and unmount many times as you scroll.
<!-- Specialized: when a row is fully described by its position (numbering,
separators, ticks, store-keyed content) read only `index` and hand the
list a length-only array - no per-row objects are materialized. -->
<script setup lang="ts">
const items = new Array(10_000); // length-only array; rows addressed by index
const ssrRange = { start: 0, end: 15 };
</script>
<template>
<VirtualScroll
virtual-scrollbar
:items="items"
:item-size="48"
:ssr-range="ssrRange"
>
<template #item="{ index }">
<div class="row">#{{ index }}</div>
</template>
</VirtualScroll>
</template>3. Give the pre-rendered slice deterministic sizes
Before hydration the component lays the range out as a real static in-flow block - normal document flow, no absolute positioning - so that markup exists in the HTML without any JavaScript. Because there is no layout pass on the server (and the first client render must match it), that block cannot be sized by ResizeObserver. Describe it with fixed sizes: a numeric item-size for uniform rows, a repeating array such as [180, 120], or a size function for per-row variation. Dynamic, measured sizing still works after hydration but cannot define the pre-rendered slice. Each row wrapper is mounted at exactly item-size tall, so the slot root must fill that box (height: 100% plus box-sizing: border-box), and the client host needs a definite height so it can scroll.
4. Extend to a grid - pre-render a rectangle of rows × columns
For a direction="both" grid the same mechanism covers two axes: set column-count and column-width alongside item-size, and add colStart/colEnd to ssrRange so the pre-rendered slice becomes a rectangle. The #item slot receives a columnRange ({ start, end }) describing the visible - or, pre-hydration, pre-rendered - column window plus a getColumnWidth() helper to size each cell; map the row across that column window exactly as in the interactive grid examples.
<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';
const items = Array.from({ length: 200 }, (_, id) => ({ id }));
// A grid pre-renders a RECTANGLE: rows AND columns. end/colEnd exclusive.
const ssrRange = { start: 100, end: 115, colStart: 50, colEnd: 70 };
</script>
<template>
<VirtualScroll
virtual-scrollbar
class="grid"
direction="both"
:items="items"
:item-size="80"
:column-count="100"
:column-width="[180, 120]"
:ssr-range="ssrRange"
>
<!-- columnRange = { start, end } of the visible/pre-rendered columns. -->
<template #item="{ index, columnRange, getColumnWidth }">
<div class="grid-row">
<div
v-for="c in columnRange.end - columnRange.start"
:key="c"
class="grid-cell"
:style="{ inlineSize: getColumnWidth(columnRange.start + c - 1) + 'px' }"
>
R{{ index }} × C{{ columnRange.start + c - 1 }}
</div>
</div>
</template>
</VirtualScroll>
</template>5. Pre-render, or jump on the client only
Two props can move the initial viewport, and which one you need depends on whether the first HTML must hold content. ssrRange embeds real HTML for the slice and scrolls to it after mount. If you only want to open the list at a deep index and need no pre-rendered markup, skip ssrRange and pass initialScrollIndex (the index to jump to on mount; default undefined) together with initialScrollAlign (default 'start') to control alignment. When ssrRange is present its start is the default jump target, which initialScrollIndex overrides if you want to land elsewhere. Either way you write no scroll code: in the tick after first layout the component performs the jump, then hydrates into the windowed, absolutely-positioned layout.
6. Pitfalls: guard window and keep the first render identical
The component itself is SSR-safe - scroll listeners and its ResizeObserver attach only inside onMounted, and it starts with isHydrated = false, so nothing touches window while rendering on the server. What you run during that render must be careful too: loading items, computing ssrRange, or anything in the slot for the pre-rendered slice must not read window/location/matchMedia, and the first client render must be byte-identical to the server output. Keep range and row content deterministic - no client-only randomness, timestamps, or async-fetch-after-mount inside the slice - by sourcing the data from the shared server path.
- Scroll Status
- Directionboth
- Current Item #- ×
- Rendered Range #0:0
- Total Size (px)0w ×0h
- Viewport Size (px)0w ×0h
- Scroll Offset (px)0x ×0y
- Controls