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

Items

0% 0.00 / 0.00 px/ms 0 / 0 items
0 selected
Source
// Scroll Save/Restore Example
// Demonstrates getScrollSnapshot() and withSnapshots({ restore }) for SPA navigation

import { vlist, withSelection, withSnapshots } from "vlist";
import { createStats } from "../stats.js";
import { createInfoUpdater } from "../info.js";

// ---------------------------------------------------------------------------
// Data
// ---------------------------------------------------------------------------

const TOTAL_ITEMS = 5000;
const ITEM_HEIGHT = 64;
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 snapshotCodeEl = document.getElementById("snapshot-code");
const savedSnapshotCodeEl = document.getElementById("saved-snapshot-code");
const navigateAwayBtn = document.getElementById("navigate-away");
const goBackBtn = document.getElementById("go-back");

// Info bar right-side element
const infoSelectedEl = document.getElementById("info-selected");

// ---------------------------------------------------------------------------
// Shared footer stats (left side — progress, velocity, items)
// ---------------------------------------------------------------------------

const stats = createStats({
  getScrollPosition: () => list?.getScrollPosition() ?? 0,
  getTotal: () => TOTAL_ITEMS,
  getItemSize: () => ITEM_HEIGHT,
  getContainerSize: () =>
    document.querySelector("#list-container")?.clientHeight ?? 0,
});

const updateInfo = createInfoUpdater(stats);

// ---------------------------------------------------------------------------
// 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: ITEM_HEIGHT,
      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();

  // Info bar updates
  list.on("scroll", updateInfo);
  list.on("range:change", updateInfo);
  list.on("velocity:change", ({ velocity }) => {
    stats.onVelocity(velocity);
    updateInfo();
  });
  list.on("selection:change", updateContext);

  updateInfo();
  updateContext();

  // 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;
  }
}

// ---------------------------------------------------------------------------
// Footer right side — context (selected count)
// ---------------------------------------------------------------------------

function updateContext() {
  if (!list || !infoSelectedEl) return;
  infoSelectedEl.textContent = list.getSelected().length;
}

// ---------------------------------------------------------------------------
// 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.
   UI components (.split-layout, .split-panel, .ui-*)
   is also provided by example/example.css. */

/* ==========================================================================
   List container
   ========================================================================== */

#list-container {
    height: 600px;
}

/* ==========================================================================
   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 code (panel + detail page)
   ========================================================================== */

.snapshot-code {
    font-family: var(--font-mono);
    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;
    margin: 0;
}

/* ==========================================================================
   Detail page (navigate-away view)
   ========================================================================== */

.hidden {
    display: none !important;
}

#detail-page {
    display: flex;
    justify-content: center;
    padding: 24px 0;
}

.detail-card {
    /* surface via .ui-card .ui-card--xl */
    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: var(--font-mono);
    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-label {
    font-size: 11px;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.5px;
    color: var(--text-dim);
    margin-bottom: 8px;
}

.saved-snapshot .snapshot-code {
    font-size: 12px;
}

.detail-card .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;
    background: var(--accent);
    color: #fff;
    margin-bottom: 12px;
}

.detail-card .btn:hover {
    opacity: 0.9;
    transform: translateY(-1px);
    box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
}

.detail-card .btn:active {
    transform: translateY(0);
    box-shadow: none;
}

.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="split-layout">
            <div class="split-main">
                <h2 class="sr-only">Items</h2>
                <div id="list-container"></div>
            </div>

            <aside class="split-panel">
                <!-- Live Snapshot -->
                <section class="ui-section">
                    <h3 class="ui-title">Live Snapshot</h3>
                    <pre class="snapshot-code" id="snapshot-code">
{
  "index": 0,
  "offsetInItem": 0
}</pre
                    >
                </section>

                <!-- Navigation -->
                <section class="ui-section">
                    <h3 class="ui-title">Navigation</h3>
                    <div class="ui-row">
                        <button
                            class="ui-btn ui-btn--primary"
                            id="navigate-away"
                        >
                            Navigate Away →
                        </button>
                    </div>
                </section>
            </aside>
        </div>
    </div>

    <!-- Detail page (hidden initially) -->
    <div id="detail-page" class="hidden">
        <div class="ui-card ui-card--xl 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>

    <div class="example-info" id="example-info">
        <div class="example-info__left">
            <span class="example-info__stat">
                <strong id="info-progress">0%</strong>
            </span>
            <span class="example-info__stat">
                <span id="info-velocity">0.00</span> /
                <strong id="info-velocity-avg">0.00</strong>
                <span class="example-info__unit">px/ms</span>
            </span>
            <span class="example-info__stat">
                <span id="info-dom">0</span> /
                <strong id="info-total">0</strong>
                <span class="example-info__unit">items</span>
            </span>
        </div>
        <div class="example-info__right">
            <span class="example-info__stat">
                <strong id="info-selected">0</strong>
                <span class="example-info__unit">selected</span>
            </span>
        </div>
    </div>
</div>