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 '@floor/vlist';
Bundle cost: Included in base (0 KB additional)
Quick Start #
import { vlist, withSnapshots } from '@floor/vlist';
const list = vlist({
container: '#list',
items: users,
item: {
height: 64,
template: (user) => `<div>${user.name}</div>`,
},
})
.use(withSnapshots())
.build();
// Save scroll position before navigation
const snapshot = list.getScrollSnapshot();
sessionStorage.setItem('list-scroll', JSON.stringify(snapshot));
// Later — restore by passing the snapshot at creation time
const saved = sessionStorage.getItem('list-scroll');
const list2 = vlist({ /* same config */ })
.use(withSnapshots(saved ? { restore: JSON.parse(saved) } : undefined))
.build();
// Scroll is restored automatically after build() — user never sees position 0
API #
withSnapshots(config?) #
Creates the snapshots feature. Accepts an optional configuration object.
withSnapshots(config?: SnapshotConfig): VListFeature
Config:
interface SnapshotConfig {
/**
* 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.
*/
restore?: ScrollSnapshot;
}
Example — auto-restore from sessionStorage:
const saved = sessionStorage.getItem('scroll');
const snapshot = saved ? JSON.parse(saved) : undefined;
const list = vlist({ ... })
.use(withSnapshots(snapshot ? { restore: snapshot } : undefined))
.build();
// If snapshot existed, scroll + selection are restored automatically
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) #
The cleanest pattern: pass the snapshot to withSnapshots({ restore }) when recreating the list.
import { vlist, withSnapshots } from '@floor/vlist';
const STORAGE_KEY = 'list-scroll';
let list;
function createList() {
const saved = sessionStorage.getItem(STORAGE_KEY);
const snapshot = saved ? JSON.parse(saved) : undefined;
list = vlist({
container: '#list',
items: users,
item: { height: 64, template: renderUser },
})
.use(withSnapshots(snapshot ? { restore: snapshot } : undefined))
.build();
}
function navigateAway() {
// Save snapshot
const snapshot = list.getScrollSnapshot();
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
// Destroy list
list.destroy();
list = null;
// Show detail page...
}
function goBack() {
// Recreate list — snapshot is restored automatically
createList();
}
SPA Navigation (Back/Forward via History API) #
import { vlist, withSnapshots } from '@floor/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();
// Save scroll position
const snapshot = list.getScrollSnapshot();
history.pushState({ scrollSnapshot: snapshot }, '', link.href);
// Navigate (load detail page)
loadDetailPage(link.href);
});
});
// When user navigates back
window.addEventListener('popstate', (e) => {
if (e.state?.scrollSnapshot) {
list.restoreScroll(e.state.scrollSnapshot);
}
});
Session Storage (Debounced Auto-Save) #
import { vlist, withSnapshots } from '@floor/vlist';
const saved = sessionStorage.getItem('product-list-scroll');
const snapshot = saved ? JSON.parse(saved) : undefined;
const list = vlist({
container: '#list',
items: products,
item: { height: 200, template: renderProduct },
})
.use(withSnapshots(snapshot ? { restore: snapshot } : undefined))
.build();
// Auto-save on scroll (debounced)
let saveTimeout;
list.on('scroll', () => {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
const snap = list.getScrollSnapshot();
sessionStorage.setItem('product-list-scroll', JSON.stringify(snap));
}, 500);
});
Tab Switching #
const lists = { recent: null, popular: null, saved: null };
const snapshots = { recent: null, popular: null, saved: null };
function switchTab(tabName) {
// Save current tab's scroll
const currentTab = getCurrentTab();
if (lists[currentTab]) {
snapshots[currentTab] = lists[currentTab].getScrollSnapshot();
}
// Switch to new tab
setCurrentTab(tabName);
// Restore new tab's scroll
if (lists[tabName] && snapshots[tabName]) {
lists[tabName].restoreScroll(snapshots[tabName]);
}
}
Local Storage (Persist Across Sessions) #
import { vlist, withSnapshots } from '@floor/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();
// Save before unload
window.addEventListener('beforeunload', () => {
const snap = list.getScrollSnapshot();
localStorage.setItem('reading-position', JSON.stringify(snap));
});
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 '@floor/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 #
When using withAsync(), pass the snapshot's total to avoid a loading flash:
import { vlist, withAsync, withSnapshots } from '@floor/vlist';
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();
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 #
For better performance, debounce snapshot saves:
let saveTimeout;
list.on('scroll', () => {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
const snapshot = list.getScrollSnapshot();
sessionStorage.setItem('scroll', JSON.stringify(snapshot));
}, 300); // Save 300ms after scroll stops
});
Compatibility #
Works With All Features #
✅ withGrid() — Saves first visible row
✅ withGroups() — Saves data index (not layout index)
✅ withAsync() — Works with lazy-loaded data (pass total from snapshot)
✅ withScale() — Compression-aware
✅ 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 `withSnapshots({ restore })` for List Recreation #
// ✅ Best — auto-restores via queueMicrotask, user never sees position 0
const list = vlist({ ... })
.use(withSnapshots({ restore: snapshot }))
.build();
Use sessionStorage for Navigation #
// ✅ Good — Clears on tab close
sessionStorage.setItem('scroll', JSON.stringify(snapshot));
Use localStorage for Long-Term Persistence #
// ✅ Good — Persists across sessions
localStorage.setItem('reading-position', JSON.stringify(snapshot));
Clear Old Snapshots #
// Clear snapshots for deleted items
function cleanupSnapshots() {
const raw = sessionStorage.getItem('scroll');
if (raw) {
const snapshot = JSON.parse(raw);
if (snapshot.total && snapshot.index >= snapshot.total) {
sessionStorage.removeItem('scroll'); // Index no longer valid
}
}
}
Validate Before Restore #
const saved = sessionStorage.getItem('scroll');
if (saved) {
try {
const snapshot = JSON.parse(saved);
// NaN/Infinity are guarded internally, but you can also check here
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: Pass the snapshot's total to withAsync():
.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 #
- Features Overview — All available features
- Builder Pattern — How to compose features
- API Methods — Complete method reference
- Examples — Interactive example
Bundle cost: Included in base (0 KB additional)
Priority: 50 (runs last — needs all other features initialized)
Methods added: getScrollSnapshot(), restoreScroll()