SSR Support

Pre-rendering and auto-scrolling for Server-Side Rendering
Demonstrates the ssrRange prop. The grid is configured to start pre-rendered at Row 100, Column 50. On the client, it automatically scrolls to match this range on mount.
In a real SSR environment, the content for this range would be present in the initial HTML.

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.

R100 × C50
180px
R100 × C51
120px
R100 × C52
180px
R100 × C53
120px
R100 × C54
180px
R100 × C55
120px
R100 × C56
180px
R100 × C57
120px
R100 × C58
180px
R100 × C59
120px
R100 × C60
180px
R100 × C61
120px
R100 × C62
180px
R100 × C63
120px
R100 × C64
180px
R100 × C65
120px
R100 × C66
180px
R100 × C67
120px
R100 × C68
180px
R100 × C69
120px
R101 × C50
180px
R101 × C51
120px
R101 × C52
180px
R101 × C53
120px
R101 × C54
180px
R101 × C55
120px
R101 × C56
180px
R101 × C57
120px
R101 × C58
180px
R101 × C59
120px
R101 × C60
180px
R101 × C61
120px
R101 × C62
180px
R101 × C63
120px
R101 × C64
180px
R101 × C65
120px
R101 × C66
180px
R101 × C67
120px
R101 × C68
180px
R101 × C69
120px
R102 × C50
180px
R102 × C51
120px
R102 × C52
180px
R102 × C53
120px
R102 × C54
180px
R102 × C55
120px
R102 × C56
180px
R102 × C57
120px
R102 × C58
180px
R102 × C59
120px
R102 × C60
180px
R102 × C61
120px
R102 × C62
180px
R102 × C63
120px
R102 × C64
180px
R102 × C65
120px
R102 × C66
180px
R102 × C67
120px
R102 × C68
180px
R102 × C69
120px
R103 × C50
180px
R103 × C51
120px
R103 × C52
180px
R103 × C53
120px
R103 × C54
180px
R103 × C55
120px
R103 × C56
180px
R103 × C57
120px
R103 × C58
180px
R103 × C59
120px
R103 × C60
180px
R103 × C61
120px
R103 × C62
180px
R103 × C63
120px
R103 × C64
180px
R103 × C65
120px
R103 × C66
180px
R103 × C67
120px
R103 × C68
180px
R103 × C69
120px
R104 × C50
180px
R104 × C51
120px
R104 × C52
180px
R104 × C53
120px
R104 × C54
180px
R104 × C55
120px
R104 × C56
180px
R104 × C57
120px
R104 × C58
180px
R104 × C59
120px
R104 × C60
180px
R104 × C61
120px
R104 × C62
180px
R104 × C63
120px
R104 × C64
180px
R104 × C65
120px
R104 × C66
180px
R104 × C67
120px
R104 × C68
180px
R104 × C69
120px
R105 × C50
180px
R105 × C51
120px
R105 × C52
180px
R105 × C53
120px
R105 × C54
180px
R105 × C55
120px
R105 × C56
180px
R105 × C57
120px
R105 × C58
180px
R105 × C59
120px
R105 × C60
180px
R105 × C61
120px
R105 × C62
180px
R105 × C63
120px
R105 × C64
180px
R105 × C65
120px
R105 × C66
180px
R105 × C67
120px
R105 × C68
180px
R105 × C69
120px
R106 × C50
180px
R106 × C51
120px
R106 × C52
180px
R106 × C53
120px
R106 × C54
180px
R106 × C55
120px
R106 × C56
180px
R106 × C57
120px
R106 × C58
180px
R106 × C59
120px
R106 × C60
180px
R106 × C61
120px
R106 × C62
180px
R106 × C63
120px
R106 × C64
180px
R106 × C65
120px
R106 × C66
180px
R106 × C67
120px
R106 × C68
180px
R106 × C69
120px
R107 × C50
180px
R107 × C51
120px
R107 × C52
180px
R107 × C53
120px
R107 × C54
180px
R107 × C55
120px
R107 × C56
180px
R107 × C57
120px
R107 × C58
180px
R107 × C59
120px
R107 × C60
180px
R107 × C61
120px
R107 × C62
180px
R107 × C63
120px
R107 × C64
180px
R107 × C65
120px
R107 × C66
180px
R107 × C67
120px
R107 × C68
180px
R107 × C69
120px
R108 × C50
180px
R108 × C51
120px
R108 × C52
180px
R108 × C53
120px
R108 × C54
180px
R108 × C55
120px
R108 × C56
180px
R108 × C57
120px
R108 × C58
180px
R108 × C59
120px
R108 × C60
180px
R108 × C61
120px
R108 × C62
180px
R108 × C63
120px
R108 × C64
180px
R108 × C65
120px
R108 × C66
180px
R108 × C67
120px
R108 × C68
180px
R108 × C69
120px
R109 × C50
180px
R109 × C51
120px
R109 × C52
180px
R109 × C53
120px
R109 × C54
180px
R109 × C55
120px
R109 × C56
180px
R109 × C57
120px
R109 × C58
180px
R109 × C59
120px
R109 × C60
180px
R109 × C61
120px
R109 × C62
180px
R109 × C63
120px
R109 × C64
180px
R109 × C65
120px
R109 × C66
180px
R109 × C67
120px
R109 × C68
180px
R109 × C69
120px
R110 × C50
180px
R110 × C51
120px
R110 × C52
180px
R110 × C53
120px
R110 × C54
180px
R110 × C55
120px
R110 × C56
180px
R110 × C57
120px
R110 × C58
180px
R110 × C59
120px
R110 × C60
180px
R110 × C61
120px
R110 × C62
180px
R110 × C63
120px
R110 × C64
180px
R110 × C65
120px
R110 × C66
180px
R110 × C67
120px
R110 × C68
180px
R110 × C69
120px
R111 × C50
180px
R111 × C51
120px
R111 × C52
180px
R111 × C53
120px
R111 × C54
180px
R111 × C55
120px
R111 × C56
180px
R111 × C57
120px
R111 × C58
180px
R111 × C59
120px
R111 × C60
180px
R111 × C61
120px
R111 × C62
180px
R111 × C63
120px
R111 × C64
180px
R111 × C65
120px
R111 × C66
180px
R111 × C67
120px
R111 × C68
180px
R111 × C69
120px
R112 × C50
180px
R112 × C51
120px
R112 × C52
180px
R112 × C53
120px
R112 × C54
180px
R112 × C55
120px
R112 × C56
180px
R112 × C57
120px
R112 × C58
180px
R112 × C59
120px
R112 × C60
180px
R112 × C61
120px
R112 × C62
180px
R112 × C63
120px
R112 × C64
180px
R112 × C65
120px
R112 × C66
180px
R112 × C67
120px
R112 × C68
180px
R112 × C69
120px
R113 × C50
180px
R113 × C51
120px
R113 × C52
180px
R113 × C53
120px
R113 × C54
180px
R113 × C55
120px
R113 × C56
180px
R113 × C57
120px
R113 × C58
180px
R113 × C59
120px
R113 × C60
180px
R113 × C61
120px
R113 × C62
180px
R113 × C63
120px
R113 × C64
180px
R113 × C65
120px
R113 × C66
180px
R113 × C67
120px
R113 × C68
180px
R113 × C69
120px
R114 × C50
180px
R114 × C51
120px
R114 × C52
180px
R114 × C53
120px
R114 × C54
180px
R114 × C55
120px
R114 × C56
180px
R114 × C57
120px
R114 × C58
180px
R114 × C59
120px
R114 × C60
180px
R114 × C61
120px
R114 × C62
180px
R114 × C63
120px
R114 × C64
180px
R114 × C65
120px
R114 × C66
180px
R114 × C67
120px
R114 × C68
180px
R114 × C69
120px
  • Scroll Status
  • Direction
    both
  • Current Item #
    - ×
  • Rendered Range #
    0:0
  • Total Size (px)
    0w ×0h
  • Viewport Size (px)
    0w ×0h
  • Scroll Offset (px)
    0x ×0y
  • Controls