Snapshots Feature #
Save and restore scroll position for SPA navigation and tab switching.
Overview #
The withSnapshots() feature enables scroll position save/restore for seamless navigation in Single Page Applications. Capture the exact scroll position and restore it later, preserving the user's place in the list — including selection state.
Import:
import { vlist, withSnapshots } from 'vlist';
Bundle cost: +0.7 KB gzipped
Quick Start #
The simplest way to persist scroll position: pass an autoSave key and everything is handled automatically — save, restore, and async data coordination.
import { vlist, withAsync, withSnapshots } from 'vlist';
const list = vlist({
container: '#list',
items: users,
item: {
height: 64,
template: (user) => `<div>${user.name}</div>`,
},
})
.use(withSnapshots({ autoSave: 'my-list' }))
.build();
// That's it — scroll position and selection are saved to sessionStorage
// on scroll idle and selection change, and restored automatically on
// the next build().
With async data, no extra plumbing is needed — withSnapshots cancels the initial load and bootstraps the total from the saved snapshot:
const list = vlist({ container: '#list', item: { height: 64, template: renderUser } })
.use(withAsync({ adapter }))
.use(withSnapshots({ autoSave: 'my-list' }))
.build();
// First visit: autoLoad fetches data normally
// Return visit: autoLoad is cancelled, scroll + selection restored from snapshot
For manual control, use restore and getScrollSnapshot() instead — see Manual Save/Restore.
API #
withSnapshots(config?) #
Creates the snapshots feature. Accepts an optional configuration object.
withSnapshots(config?: SnapshotConfig): VListFeature
Config:
interface SnapshotConfig {
/**
* Automatically save and restore snapshots via sessionStorage.
*
* Pass a string key — snapshots are saved whenever scroll becomes
* idle or selection changes, and restored automatically on the
* next build(). When withAsync is present, autoLoad is cancelled
* and the total is bootstrapped from the snapshot.
*/
autoSave?: string;
/**
* Snapshot to restore automatically after build() completes.
*
* When provided, restoreScroll() is scheduled via queueMicrotask —
* it runs right after .build() returns but before the browser paints,
* so the user never sees position 0.
*
* Ignored when autoSave is set (autoSave reads from sessionStorage).
*/
restore?: ScrollSnapshot;
}
Example — automatic (recommended):
const list = vlist({ ... })
.use(withSnapshots({ autoSave: 'my-list' }))
.build();
// Saves on scroll idle + selection change, restores on next build()
Example — manual restore:
const saved = sessionStorage.getItem('scroll');
const snapshot = saved ? JSON.parse(saved) : undefined;
const list = vlist({ ... })
.use(withSnapshots(snapshot ? { restore: snapshot } : undefined))
.build();
getScrollSnapshot() #
Captures the current scroll position, total item count, and selection state.
list.getScrollSnapshot(): ScrollSnapshot
Returns:
interface ScrollSnapshot {
index: number; // First visible item index
offsetInItem: number; // Pixels scrolled into that item
total?: number; // Total item count at snapshot time
selectedIds?: Array<string | number>; // Selected IDs (if selection is active)
}
Example:
const snapshot = list.getScrollSnapshot();
// { index: 523, offsetInItem: 12, total: 5000, selectedIds: [3, 7, 42] }
The total field is included automatically so that the snapshot is self-contained — useful when restoring with withAsync() where you need to set the initial total.
`restoreScroll(snapshot)` #
Restores scroll position from a snapshot. Can also be called manually after build.
list.restoreScroll(snapshot: ScrollSnapshot): void
Parameters:
snapshot- Snapshot object fromgetScrollSnapshot()
Example:
list.restoreScroll({ index: 523, offsetInItem: 12, total: 5000 });
Prefer
withSnapshots({ restore })over manualrestoreScroll()when recreating a list — it handles timing automatically viaqueueMicrotask.
Use Cases #
SPA Navigation — Destroy & Recreate (Recommended) #
With autoSave, the list handles everything — no manual save/restore code needed:
import { vlist, withSnapshots } from 'vlist';
let list;
function createList() {
list = vlist({
container: '#list',
items: users,
item: { height: 64, template: renderUser },
})
.use(withSnapshots({ autoSave: 'user-list' }))
.build();
// On return visits, scroll position is restored automatically
}
function navigateAway() {
// Snapshot is already saved (autoSave handles it on scroll idle)
list.destroy();
list = null;
}
function goBack() {
createList(); // Restored from sessionStorage automatically
}
With Async Data Use Cases #
No extra plumbing needed — withSnapshots({ autoSave }) coordinates with withAsync() automatically:
import { vlist, withAsync, withSnapshots } from 'vlist';
const list = vlist({
container: '#list',
item: { height: 64, template: renderUser },
})
.use(withAsync({ adapter }))
.use(withSnapshots({ autoSave: 'user-list' }))
.build();
// First visit: autoLoad fetches from offset 0
// Return visit: autoLoad is cancelled, total bootstrapped from snapshot,
// scroll restored, data loaded at the restored position
For runtime reloads (e.g., switching data sources), pass the snapshot to reload() directly:
// Save snapshot before switching
const snapshot = list.getScrollSnapshot();
sessionStorage.setItem('current-tab', JSON.stringify(snapshot));
// Switch data source and restore in one call
const saved = sessionStorage.getItem('other-tab');
const otherSnapshot = saved ? JSON.parse(saved) : undefined;
await list.reload(otherSnapshot ? { snapshot: otherSnapshot } : undefined);
Tab Switching #
For synchronous lists (no withAsync), you can use restoreScroll() directly:
const lists = { recent: null, popular: null, saved: null };
const snapshots = { recent: null, popular: null, saved: null };
function switchTab(tabName) {
const currentTab = getCurrentTab();
if (lists[currentTab]) {
snapshots[currentTab] = lists[currentTab].getScrollSnapshot();
}
setCurrentTab(tabName);
if (lists[tabName] && snapshots[tabName]) {
lists[tabName].restoreScroll(snapshots[tabName]);
}
}
For tabs with async data (using withAsync()), prefer reload({ snapshot }):
const lists = { recent: null, popular: null, saved: null };
const snapshots = { recent: null, popular: null, saved: null };
async function switchTab(tabName) {
const currentTab = getCurrentTab();
if (lists[currentTab]) {
snapshots[currentTab] = lists[currentTab].getScrollSnapshot();
}
setCurrentTab(tabName);
await lists[tabName].reload(
snapshots[tabName] ? { snapshot: snapshots[tabName] } : undefined
);
}
SPA Navigation (Back/Forward via History API) #
import { vlist, withSnapshots } from 'vlist';
const list = vlist({
container: '#list',
items: users,
item: { height: 64, template: renderUser },
})
.use(withSnapshots())
.build();
// Before navigating to detail page
document.querySelectorAll('.user-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const snapshot = list.getScrollSnapshot();
history.pushState({ scrollSnapshot: snapshot }, '', link.href);
loadDetailPage(link.href);
});
});
// When user navigates back
window.addEventListener('popstate', (e) => {
if (e.state?.scrollSnapshot) {
list.restoreScroll(e.state.scrollSnapshot);
}
});
Local Storage (Persist Across Sessions) #
For long-term persistence (e.g., reading position), use manual save to localStorage:
import { vlist, withSnapshots } from 'vlist';
const saved = localStorage.getItem('reading-position');
const snapshot = saved ? JSON.parse(saved) : undefined;
const list = vlist({
container: '#list',
items: articles,
item: { height: 300, template: renderArticle },
})
.use(withSnapshots(snapshot ? { restore: snapshot } : undefined))
.build();
window.addEventListener('beforeunload', () => {
const snap = list.getScrollSnapshot();
localStorage.setItem('reading-position', JSON.stringify(snap));
});
Manual Save/Restore #
When autoSave doesn't fit (e.g., custom storage, conditional saves), use the manual pattern:
import { vlist, withSnapshots } from 'vlist';
const saved = sessionStorage.getItem('list-scroll');
const snapshot = saved ? JSON.parse(saved) : undefined;
const list = vlist({
container: '#list',
items: users,
item: { height: 64, template: renderUser },
})
.use(withSnapshots(snapshot ? { restore: snapshot } : undefined))
.build();
// Save before navigating away
function navigateAway() {
const snap = list.getScrollSnapshot();
sessionStorage.setItem('list-scroll', JSON.stringify(snap));
list.destroy();
}
How It Works #
Snapshot Structure #
Instead of saving raw scrollTop pixels, snapshots save:
- Item index — Which item is at the top of the viewport
- Offset within item — How many pixels into that item
- Total — Total item count at snapshot time
- Selected IDs — Selection state (if
withSelection()is active)
Why this approach?
✅ Survives list recreation — Index-based, not pixel-based ✅ Works with compression — Independent of virtual height ✅ Handles data changes — Restores to same item even if list changed ✅ Works with variable heights — Doesn't depend on total height ✅ Self-contained — Total and selection included in one JSON blob
Example #
// User scrolled to item 500, 12 pixels into it
const snapshot = list.getScrollSnapshot();
// { index: 500, offsetInItem: 12, total: 5000 }
// Later, recreate the list with the snapshot
const newList = vlist({ ... })
.use(withSnapshots({ restore: snapshot }))
.build();
// Scrolls to item 500, 12 pixels in — automatically
Auto-Restore Timing #
When you pass restore to withSnapshots():
build()runs all feature setup synchronouslyqueueMicrotaskschedulesrestoreScroll()- Restoration runs before the browser paints
- User never sees position 0
This is more reliable than calling restoreScroll() manually after build(), because the microtask timing is guaranteed to fire before the next paint.
Advanced Usage #
With Selection State #
When withSelection() is installed, snapshots automatically include selection:
import { vlist, withSelection, withSnapshots } from 'vlist';
const list = vlist({ ... })
.use(withSelection({ mode: 'multiple' }))
.use(withSnapshots())
.build();
// Select some items
list.select(3, 7, 42);
// Save — selectedIds are included automatically
const snapshot = list.getScrollSnapshot();
// { index: 0, offsetInItem: 0, total: 5000, selectedIds: [3, 7, 42] }
sessionStorage.setItem('list-state', JSON.stringify(snapshot));
// Restore — selection is restored automatically too
const saved = JSON.parse(sessionStorage.getItem('list-state'));
const list2 = vlist({ ... })
.use(withSelection({ mode: 'multiple' }))
.use(withSnapshots({ restore: saved }))
.build();
// Scroll position AND selection are both restored
No need to save or restore selection separately — it's all in the snapshot.
With Async Data Advanced Usage #
With autoSave, no manual coordination is needed:
const list = vlist({ ... })
.use(withAsync({ adapter }))
.use(withSnapshots({ autoSave: 'my-list' }))
.build();
// withSnapshots reads the saved snapshot, cancels autoLoad, bootstraps
// the total, restores scroll, and loads data at the restored position.
With restore (manual), pass the snapshot to both features:
const saved = sessionStorage.getItem('scroll');
const snapshot = saved ? JSON.parse(saved) : undefined;
const list = vlist({ ... })
.use(withAsync({
adapter,
autoLoad: !snapshot, // Skip autoLoad when restoring
total: snapshot?.total, // Use snapshot total — no hardcoded constant
}))
.use(withSnapshots(snapshot ? { restore: snapshot } : undefined))
.build();
Runtime reload — pass the snapshot to reload() directly:
await list.reload(snapshot ? { snapshot } : undefined);
// Seeds total → skips page 1 → restores scroll → loads target page
With Filters/Sorting #
// Save snapshot per filter state
const snapshots = new Map();
function applyFilter(filterKey) {
// Save current filter's scroll
const currentFilter = getCurrentFilter();
snapshots.set(currentFilter, list.getScrollSnapshot());
// Apply new filter
const filtered = applyFilterLogic(allItems, filterKey);
list.setItems(filtered);
// Restore new filter's scroll if exists
const snapshot = snapshots.get(filterKey);
if (snapshot) {
list.restoreScroll(snapshot);
}
}
Debounced Save #
Note: With
autoSave, saves are already debounced — they fire onscroll:idle(150ms after scrolling stops) and onselection:change. No manual debouncing needed.
For manual save patterns, debounce with a timeout:
let saveTimeout;
list.on('scroll', () => {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
const snapshot = list.getScrollSnapshot();
sessionStorage.setItem('scroll', JSON.stringify(snapshot));
}, 300);
});
Compatibility #
Works With All Features #
✅ withGrid() — Saves first visible row
✅ withGroups() — Saves data index (not layout index)
✅ withAsync() — autoSave cancels autoLoad and bootstraps total automatically
✅ withScale() — Compression-aware save/restore
✅ withPage() — Works with page-level scrolling
✅ withSelection() — Selection automatically included in snapshots
Platform Support #
✅ All browsers with sessionStorage / localStorage
✅ Works with browser back/forward navigation
✅ Works with SPA routers (React Router, Vue Router, etc.)
Best Practices #
Use `autoSave` for Most Cases #
// ✅ Best — fully automatic, no manual plumbing
const list = vlist({ ... })
.use(withSnapshots({ autoSave: 'my-list' }))
.build();
Use `restore` for Custom Storage #
// ✅ Good — manual control when autoSave doesn't fit (localStorage, History API, etc.)
const list = vlist({ ... })
.use(withSnapshots({ restore: snapshot }))
.build();
Use sessionStorage for Navigation #
// ✅ Good — Clears on tab close (autoSave uses this automatically)
sessionStorage.setItem('scroll', JSON.stringify(snapshot));
Use localStorage for Long-Term Persistence #
// ✅ Good — Persists across sessions (use manual restore pattern)
localStorage.setItem('reading-position', JSON.stringify(snapshot));
Clear Old Snapshots #
function cleanupSnapshots() {
const raw = sessionStorage.getItem('my-list');
if (raw) {
const snapshot = JSON.parse(raw);
if (snapshot.total && snapshot.index >= snapshot.total) {
sessionStorage.removeItem('my-list'); // Index no longer valid
}
}
}
Validate Before Manual Restore #
// NaN/Infinity are guarded internally, but you can validate defensively:
const saved = sessionStorage.getItem('scroll');
if (saved) {
try {
const snapshot = JSON.parse(saved);
if (Number.isFinite(snapshot.index)) {
list.restoreScroll(snapshot);
}
} catch (e) {
console.warn('Invalid snapshot:', e);
}
}
Troubleshooting #
Scroll doesn't restore correctly #
Problem: List items changed between save and restore
Solution: Snapshots are index-based. If the item at that index is different, scroll position will be "correct" but content will differ.
Scroll jumps on restore #
Problem: Variable height items and heights changed
Solution: Ensure heights are consistent between save and restore, or remeasure items before restoring.
Snapshot seems outdated #
Problem: Snapshot saved for different dataset
Solution: Include dataset version in snapshot:
const snapshot = {
...list.getScrollSnapshot(),
version: dataVersion,
timestamp: Date.now(),
};
restoreScroll does nothing (total is 0) #
Problem: With withAsync(), the total hasn't been set yet when restoring.
Solution A — Use autoSave (recommended):
autoSave handles async coordination automatically — it cancels autoLoad, bootstraps the total from the saved snapshot, and restores scroll position:
.use(withAsync({ adapter }))
.use(withSnapshots({ autoSave: 'my-list' }))
Solution B — Use reload({ snapshot }) for runtime reloads:
await list.reload({ snapshot: savedSnapshot });
// Total is set from snapshot.total → restoreScroll works immediately
Solution C — Pass total manually at creation time:
.use(withAsync({
adapter,
autoLoad: !snapshot,
total: snapshot?.total,
}))
.use(withSnapshots(snapshot ? { restore: snapshot } : undefined))
Corrupt snapshot data (NaN values) #
Problem: Snapshot was corrupted in storage (e.g. partial JSON, undefined fields).
Solution: restoreScroll() internally guards against NaN and Infinity — it silently no-ops. You don't need to validate manually, but you can if you want defensive code.
See Also #
- Types — `ScrollSnapshot` —
index,offsetInItem,total,selectedIds - Types — `ReloadOptions` —
snapshotoption forreload() - Types — `ScrollToOptions` —
align,behavior,duration - Selection — Selection state included in snapshots automatically
- Async —
autoSavecoordinates withwithAsyncautomatically, or usereload({ snapshot })at runtime
Examples #
- Scroll Restore — Save and restore scroll position across navigations
- Velocity Loading —
autoSavewith async loading, scale, and selection