/ Examples
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.

Scroll to see stats
Live snapshot preview
{ index: 0, offsetInItem: 0 }

Uses getScrollSnapshot() and withSnapshots({ restore }) — snapshots are plain JSON, perfect for sessionStorage. ✨

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 &amp; 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>