snapshotsselection
Scroll Save/Restore
Scroll and select items, then "navigate away". When you come back, the scroll position and selection are perfectly restored from a JSON snapshot — just like a real SPA.
Live snapshot preview
{ index: 0, offsetInItem: 0 }
You navigated away
The list has been destroyed. The scroll
snapshot was saved to sessionStorage.
Saved snapshot
The list will be recreated and the scroll position + selection will be restored from the saved snapshot.
Source
// Scroll Save/Restore Example
// Demonstrates getScrollSnapshot() and withSnapshots({ restore }) for SPA navigation
import { vlist, withSelection, withSnapshots } from "vlist";
// ---------------------------------------------------------------------------
// Data
// ---------------------------------------------------------------------------
const TOTAL_ITEMS = 5000;
const DEPARTMENTS = [
"Engineering",
"Design",
"Marketing",
"Sales",
"Support",
"Finance",
"Legal",
"Operations",
];
const COLORS = [
"#667eea",
"#f093fb",
"#4facfe",
"#43e97b",
"#fa709a",
"#fee140",
"#30cfd0",
"#ff6b6b",
];
const items = Array.from({ length: TOTAL_ITEMS }, (_, i) => ({
id: i + 1,
name: `Employee ${i + 1}`,
department: DEPARTMENTS[i % DEPARTMENTS.length],
initials: String.fromCharCode(65 + (i % 26)),
color: COLORS[i % COLORS.length],
}));
// ---------------------------------------------------------------------------
// Storage key
// ---------------------------------------------------------------------------
const STORAGE_KEY = "vlist-scroll-restore-demo";
// ---------------------------------------------------------------------------
// DOM references
// ---------------------------------------------------------------------------
const listPage = document.getElementById("list-page");
const detailPage = document.getElementById("detail-page");
const listContainer = document.getElementById("list-container");
const statsEl = document.getElementById("stats");
const snapshotCodeEl = document.getElementById("snapshot-code");
const savedSnapshotCodeEl = document.getElementById("saved-snapshot-code");
const navigateAwayBtn = document.getElementById("navigate-away");
const goBackBtn = document.getElementById("go-back");
// ---------------------------------------------------------------------------
// List management
// ---------------------------------------------------------------------------
let list = null;
let snapshotUpdateId = null;
/**
* Create (or recreate) the list.
*
* @param {import('vlist').ScrollSnapshot} [snapshot]
* Optional snapshot to restore automatically after build().
* When provided it is passed to `withSnapshots({ restore })` which
* schedules `restoreScroll()` via `queueMicrotask` — the user never
* sees position 0.
*/
function createList(snapshot) {
list = vlist({
container: listContainer,
ariaLabel: "Employee list",
item: {
height: 64,
template: (item, index, { selected }) => {
const selectedClass = selected ? " item--selected" : "";
return `
<div class="item-content${selectedClass}">
<div class="item-avatar" style="background:${item.color}">${item.initials}</div>
<div class="item-details">
<div class="item-name">${item.name}</div>
<div class="item-dept">${item.department}</div>
</div>
<div class="item-index">#${index + 1}</div>
</div>
`;
},
},
items,
})
.use(withSelection({ mode: "multiple" }))
.use(withSnapshots(snapshot ? { restore: snapshot } : undefined))
.build();
// Live stats
const updateStats = () => {
const domNodes = listContainer.querySelectorAll(".vlist-item").length;
const selected = list.getSelected().length;
statsEl.innerHTML = `
<span><strong>${TOTAL_ITEMS.toLocaleString()}</strong> items</span>
<span class="stats-sep">·</span>
<span><strong>${domNodes}</strong> DOM nodes</span>
<span class="stats-sep">·</span>
<span><strong>${selected}</strong> selected</span>
`;
};
list.on("scroll", updateStats);
list.on("range:change", updateStats);
list.on("selection:change", updateStats);
updateStats();
// Live snapshot preview (throttled)
const updateSnapshotPreview = () => {
if (!list) return;
const snap = list.getScrollSnapshot();
snapshotCodeEl.textContent = formatSnapshot(snap);
snapshotUpdateId = null;
};
const scheduleSnapshotUpdate = () => {
if (snapshotUpdateId) return;
snapshotUpdateId = requestAnimationFrame(updateSnapshotPreview);
};
list.on("scroll", scheduleSnapshotUpdate);
list.on("selection:change", scheduleSnapshotUpdate);
// Initial preview
updateSnapshotPreview();
}
function destroyList() {
if (snapshotUpdateId) {
cancelAnimationFrame(snapshotUpdateId);
snapshotUpdateId = null;
}
if (list) {
list.destroy();
list = null;
}
}
// ---------------------------------------------------------------------------
// Snapshot formatting
// ---------------------------------------------------------------------------
function formatSnapshot(snapshot) {
const parts = [
` "index": ${snapshot.index}`,
` "offsetInItem": ${Math.round(snapshot.offsetInItem * 100) / 100}`,
` "total": ${snapshot.total}`,
];
if (snapshot.selectedIds && snapshot.selectedIds.length > 0) {
const ids = snapshot.selectedIds;
if (ids.length <= 8) {
parts.push(` "selectedIds": [${ids.join(", ")}]`);
} else {
const preview = ids.slice(0, 6).join(", ");
parts.push(` "selectedIds": [${preview}, … +${ids.length - 6} more]`);
}
}
return "{\n" + parts.join(",\n") + "\n}";
}
// ---------------------------------------------------------------------------
// Navigation
// ---------------------------------------------------------------------------
function navigateAway() {
if (!list) return;
// 1. Save snapshot
const snapshot = list.getScrollSnapshot();
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
// 2. Show the saved snapshot on the detail page
savedSnapshotCodeEl.textContent = formatSnapshot(snapshot);
// 3. Destroy the list
destroyList();
// 4. Switch pages
listPage.classList.add("hidden");
detailPage.classList.remove("hidden");
}
function goBack() {
// 1. Switch pages
detailPage.classList.add("hidden");
listPage.classList.remove("hidden");
// 2. Read saved snapshot
const raw = sessionStorage.getItem(STORAGE_KEY);
const snapshot = raw ? JSON.parse(raw) : undefined;
// 3. Recreate the list — snapshot is passed to withSnapshots({ restore })
// so scroll + selection are restored automatically after build().
createList(snapshot);
}
// ---------------------------------------------------------------------------
// Event listeners
// ---------------------------------------------------------------------------
navigateAwayBtn.addEventListener("click", navigateAway);
goBackBtn.addEventListener("click", goBack);
// ---------------------------------------------------------------------------
// Pre-select a few items so there's something to restore
// ---------------------------------------------------------------------------
function init() {
// Check for a previously saved snapshot (e.g. hard page refresh)
const raw = sessionStorage.getItem(STORAGE_KEY);
let snapshot;
if (raw) {
try {
snapshot = JSON.parse(raw);
} catch {
// Ignore corrupted data
}
sessionStorage.removeItem(STORAGE_KEY);
}
// Create the list — if a snapshot exists it is passed directly to
// withSnapshots({ restore }) for automatic restoration.
createList(snapshot);
// Pre-select a handful of items to make the demo more interesting
// (only when there's no snapshot to restore — otherwise the snapshot
// already carries its own selectedIds).
if (!snapshot) {
list.select(3, 7, 12, 25, 42);
}
}
init();
/* Scroll Save/Restore — example-specific styles only
Common styles (.container, h1, .description, .stats, footer)
are provided by examples/examples.css using shell.css design tokens. */
/* ==========================================================================
Toolbar
========================================================================== */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.stats {
gap: 8px;
background: transparent;
border: none;
padding: 0;
margin-bottom: 0;
}
.stats-sep {
color: var(--border);
}
/* ==========================================================================
Buttons
========================================================================== */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.15s ease;
white-space: nowrap;
}
.btn--primary {
background: var(--accent);
color: #fff;
}
.btn--primary:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
}
.btn--primary:active {
transform: translateY(0);
box-shadow: none;
}
/* ==========================================================================
List container
========================================================================== */
#list-container {
height: 450px;
}
/* ==========================================================================
Item styles
========================================================================== */
.item-content {
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
height: 100%;
transition: background 0.1s ease;
}
.item-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 16px;
flex-shrink: 0;
}
.item-details {
flex: 1;
min-width: 0;
}
.item-name {
font-weight: 500;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-dept {
font-size: 13px;
color: var(--text-dim);
}
.item-index {
font-size: 12px;
color: var(--text-dim);
min-width: 50px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ==========================================================================
Snapshot panel
========================================================================== */
.snapshot-panel {
margin-top: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 18px;
}
.snapshot-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
margin-bottom: 8px;
}
.snapshot-code {
font-family: "SF Mono", Monaco, Menlo, monospace;
font-size: 13px;
line-height: 1.6;
color: var(--accent-text);
background: var(--bg);
border: 1px solid var(--border);
padding: 10px 14px;
border-radius: 6px;
overflow-x: auto;
white-space: pre;
}
/* ==========================================================================
Detail page (navigate-away view)
========================================================================== */
.hidden {
display: none !important;
}
#detail-page {
display: flex;
justify-content: center;
padding: 24px 0;
}
.detail-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 40px 48px;
text-align: center;
max-width: 520px;
width: 100%;
}
.detail-icon {
font-size: 48px;
margin-bottom: 16px;
}
.detail-card h2 {
font-size: 24px;
font-weight: 700;
color: var(--text);
margin-bottom: 10px;
}
.detail-card > p {
font-size: 15px;
color: var(--text-muted);
line-height: 1.6;
margin-bottom: 20px;
}
.detail-card code {
font-family: "SF Mono", Monaco, Menlo, monospace;
font-size: 13px;
background: var(--bg);
border: 1px solid var(--border);
padding: 1px 6px;
border-radius: 4px;
color: var(--text-muted);
}
.saved-snapshot {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 18px;
margin-bottom: 24px;
text-align: left;
}
.saved-snapshot .snapshot-code {
font-size: 12px;
}
.detail-card .btn {
margin-bottom: 12px;
}
.detail-hint {
font-size: 13px;
color: var(--text-dim);
margin-top: 4px;
}
<div class="container">
<header>
<h1>Scroll Save/Restore</h1>
<p class="description">
Scroll and select items, then "navigate away". When you come back,
the scroll position and selection are perfectly restored from a JSON
snapshot — just like a real SPA.
</p>
</header>
<!-- List page -->
<div id="list-page">
<div class="toolbar">
<div class="toolbar-left">
<div class="stats" id="stats">Scroll to see stats</div>
</div>
<div class="toolbar-right">
<button class="btn btn--primary" id="navigate-away">
Navigate Away →
</button>
</div>
</div>
<div id="list-container"></div>
<div class="snapshot-panel" id="snapshot-panel">
<div class="snapshot-label">Live snapshot preview</div>
<pre class="snapshot-code" id="snapshot-code">
{ index: 0, offsetInItem: 0 }</pre
>
</div>
</div>
<!-- Detail page (hidden initially) -->
<div id="detail-page" class="hidden">
<div class="detail-card">
<div class="detail-icon">📄</div>
<h2>You navigated away</h2>
<p>
The list has been <strong>destroyed</strong>. The scroll
snapshot was saved to <code>sessionStorage</code>.
</p>
<div class="saved-snapshot">
<div class="snapshot-label">Saved snapshot</div>
<pre class="snapshot-code" id="saved-snapshot-code"></pre>
</div>
<button class="btn btn--primary" id="go-back">
← Go Back & Restore
</button>
<p class="detail-hint">
The list will be recreated and the scroll position + selection
will be restored from the saved snapshot.
</p>
</div>
</div>
<footer>
<p>
Uses <code>getScrollSnapshot()</code> and
<code>withSnapshots({ restore })</code> — snapshots are plain JSON,
perfect for <code>sessionStorage</code>. ✨
</p>
</footer>
</div>