Scroll Restoration
How to build a feature like this
Prepending rows - new messages, live-updating feeds - moves everything the user is reading down by the height of the inserted rows, which makes the viewport visibly jump unless the scroll offset is corrected by the same amount. The restoreScrollOnPrepend prop (default false) does that correction for you: when it detects rows added to the front of the list, it re-issues the scroll offset so the content that was on screen stays exactly where it was - effectively anchoring the view to the first visible item instead of to the top of the document. Its main caveat: detection is by reference identity, so it only works when you prepend a new array while keeping the existing item objects untouched.
1. Constrain the scroll box and model reference-stable rows
As always the list needs a definite height. More importantly, restoration is triggered by the list engine comparing the previous and the new items arrays; it counts prepended rows by locating the old first item (by object reference) inside the new array's prefix. Keep each existing item object untouched across prepends and always assign a fresh array - the engine watches by identity (deep: false), so mutating the same array in place never fires. Replace the whole dataset or re-sort so the old first object is gone and no correction happens.
2. Prepend a new array on top of the old one
To prepend, you bind :restore-scroll-on-prepend="true" and reassign the array with the fresh rows spread in front of the previous ones - that spread is all the data side needs. The engine measures the inserted block's size and shifts the scroll offset by exactly that much, so if you scroll down a short distance and then prepend, the rows already on screen stay put. The batch size is arbitrary: any number of rows inserted in a single array replacement triggers one correction for their combined height.
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';
import { ref } from 'vue';
const items = ref(Array.from({ length: 50 }, (_, i) => `Item ${ i }`));
let batch = 0;
function prepend(count = 5) {
const fresh = Array.from(
{ length: count },
(_, i) => `new-${ batch }-${ i }`,
);
batch += 1;
// Keep the previous objects untouched: restoration identifies how many rows
// were inserted by locating the OLD first item (by reference) in the prefix
// of the new array. Always assign a fresh array (the watcher is identity).
items.value = [...fresh, ...items.value];
}
</script>
<template>
<VirtualScroll
virtual-scrollbar
class="list"
:items="items"
:item-size="60"
:restore-scroll-on-prepend="true"
>
<template #item="{ item, index }">
<div class="row">#{{ index }} · {{ item }}</div>
</template>
</VirtualScroll>
</template>
<style scoped>
.list {
height: 480px;
}
.row {
box-sizing: border-box;
display: flex;
align-items: center;
height: 100%;
padding-inline: 1rem;
border-bottom: 1px solid rgb(0 0 0 / 0.1);
}
</style>3. What restoration guarantees - and its limits
The guarantee is that the correction happens after the inserted rows' sizes are known, so the rows that were visible remain at the same screen offsets (the item at the top of the viewport stays at the top). It is exact for a fixed item-size. With dynamic sizes the shift uses the current size oracle, so freshly inserted rows are estimated until ResizeObserver measures them - expect a tiny settle, not a wrong final position.
- Reference identity: replacing the whole dataset (the old first object disappears) yields a prepend count of
0, so no correction runs. - Identity watcher: mutating the existing array in place never triggers restoration; always assign a new array.
- Top of the list: restoration applies even at offset 0 - the engine re-issues the offset to the inserted height, so the new rows appear above the fold instead of pushing the old content down.
- Scroll Status
- Directionvertical
- Current Item #-
- Rendered Range #0:0
- Total Size (px)0h
- Viewport Size (px)0h
- Scroll Offset (px)0y