9.6 KiB
Scroll Restoration Implementation for Custom Containers
Problem Description
In our SvelteKit application, scroll position isn't resetting when navigating between pages. This issue occurs because:
- The application uses a custom scrolling container (
.main-content) instead of the default window/body scrolling - SvelteKit's built-in scroll restoration only works with window-level scrolling
- When navigating from a scrolled profile page to a team detail page, the detail page appears scrolled down instead of at the top
User Experience Impact
- Users scroll down on a profile page
- Click on a team to view details
- The team detail page is already scrolled down (unexpected)
- This breaks the expected navigation behavior where new pages start at the top
Research Findings
SvelteKit's Default Behavior
- SvelteKit automatically handles scroll restoration for window-level scrolling
- It stores scroll positions in
sessionStoragefor browser back/forward navigation - The
afterNavigateandbeforeNavigatehooks provide navigation lifecycle control - The navigation
typeparameter distinguishes between different navigation methods
Limitations with Custom Scroll Containers
- SvelteKit's scroll handling doesn't automatically work with custom containers (GitHub issues #937, #2733)
- The framework only tracks
window.scrollY, not element-specific scroll positions - Using
disableScrollHandling()is discouraged as it "breaks user expectations" (official docs)
Community Solutions
- Manual scroll management using navigation hooks
- Combining
beforeNavigatefor saving positions withafterNavigatefor restoration - Using the snapshot API for session persistence
- Leveraging
requestAnimationFrameto ensure DOM readiness
Solution Architecture
Core Components
- Scroll Position Storage: A Map that stores scroll positions keyed by URL
- Navigation Hooks: Using
beforeNavigateandafterNavigatefor lifecycle management - Navigation Type Detection: Using the
typeparameter to distinguish navigation methods - DOM Reference: Direct reference to the
.main-contentscrolling container
Navigation Type Disambiguation
The solution uses SvelteKit's navigation type to determine the appropriate scroll behavior:
| Navigation Type | Value | Behavior | Example |
|---|---|---|---|
| Initial Load | 'enter' |
Scroll to top | First visit to the app |
| Link Click | 'link' |
Scroll to top | Clicking <a> tags |
| Programmatic | 'goto' |
Scroll to top | Using goto() function |
| Browser Navigation | 'popstate' |
Restore position | Back/forward buttons |
| Leave App | 'leave' |
N/A | Navigating away |
Implementation
Complete Solution Code
Add the following to /src/routes/+layout.svelte:
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation'
import { browser } from '$app/environment'
// ... other imports
// Reference to the scrolling container
let mainContent: HTMLElement | undefined;
// Store scroll positions for each visited route
const scrollPositions = new Map<string, number>();
// Save scroll position before navigating away
beforeNavigate(({ from }) => {
if (from && mainContent) {
// Create a unique key including pathname and query params
const key = from.url.pathname + from.url.search;
scrollPositions.set(key, mainContent.scrollTop);
}
});
// Handle scroll restoration or reset after navigation
afterNavigate(({ from, to, type }) => {
if (!mainContent) return;
// Use requestAnimationFrame to ensure DOM has updated
requestAnimationFrame(() => {
const key = to.url.pathname + to.url.search;
// Only restore scroll for browser back/forward navigation
if (type === 'popstate' && scrollPositions.has(key)) {
// User clicked back/forward button - restore their position
mainContent.scrollTop = scrollPositions.get(key) || 0;
} else {
// Any other navigation type (link, goto, enter, etc.) - go to top
mainContent.scrollTop = 0;
}
});
});
// Optional: Export snapshot for session persistence
export const snapshot = {
capture: () => {
if (!mainContent) return { scroll: 0, positions: [] };
return {
scroll: mainContent.scrollTop,
positions: Array.from(scrollPositions.entries())
};
},
restore: (data) => {
if (!data || !mainContent) return;
// Restore saved positions map
if (data.positions) {
scrollPositions.clear();
data.positions.forEach(([key, value]) => {
scrollPositions.set(key, value);
});
}
// Restore current scroll position after DOM is ready
if (browser) {
requestAnimationFrame(() => {
if (mainContent) mainContent.scrollTop = data.scroll;
});
}
}
};
</script>
<!-- Update the main content element to include the reference -->
<main class="main-content" bind:this={mainContent}>
{@render children?.()}
</main>
Integration Steps
-
Import Navigation Hooks
import { afterNavigate, beforeNavigate } from '$app/navigation' -
Add Container Reference Change the
<main>element to includebind:this={mainContent} -
Initialize Scroll Position Map Create a Map to store positions:
const scrollPositions = new Map<string, number>() -
Implement Navigation Handlers Add the
beforeNavigateandafterNavigatecallbacks as shown above -
Optional: Add Snapshot Support Export the snapshot object for session persistence across refreshes
Navigation Scenarios
1. Back/Forward Button Navigation
- Detection:
type === 'popstate' - Action: Restore saved scroll position if it exists
- Example: User views profile → scrolls down → clicks team → clicks back button → returns to scrolled position
2. Link Click Navigation
- Detection:
type === 'link' - Action: Reset scroll to top
- Example: User clicks on any
<a>tag or navigation link → new page starts at top
3. Page Refresh
- Detection: Map is empty after refresh (unless snapshot is used)
- Action: Start at top (default behavior)
- Example: User refreshes browser → page loads at top
4. Programmatic Navigation
- Detection:
type === 'goto' - Action: Reset scroll to top
- Example: Code calls
goto('/teams')→ page starts at top
5. Direct URL Access
- Detection:
type === 'enter' - Action: Start at top
- Example: User enters URL directly or opens bookmark → page starts at top
Edge Cases
Scenario: Refresh Then Back
- User refreshes page (Map is cleared)
- User navigates back
- Result: Scrolls to top (no stored position)
Scenario: Same URL Different Navigation
- Via link click: Always scrolls to top
- Via back button: Restores position if available
Scenario: Query Parameters
- Positions are stored with full path + query
/teams?page=2and/teams?page=3have separate positions
Scenario: Memory Management
- Positions accumulate during session
- Cleared on page refresh (unless using snapshot)
- Consider implementing a size limit for long sessions
Best Practices
1. Avoid disableScrollHandling
The official documentation states this is "generally discouraged, since it breaks user expectations." Our solution works alongside SvelteKit's default behavior.
2. Use requestAnimationFrame
Ensures the DOM has fully updated before manipulating scroll position:
requestAnimationFrame(() => {
mainContent.scrollTop = position;
});
3. Include Query Parameters in Keys
Important for paginated views where each page should maintain its own scroll position:
const key = url.pathname + url.search;
4. Progressive Enhancement
The solution gracefully degrades if JavaScript is disabled, falling back to default browser behavior.
5. Type Safety
Use TypeScript types for better maintainability:
let mainContent: HTMLElement | undefined;
const scrollPositions = new Map<string, number>();
Testing Checklist
- Forward navigation resets scroll to top
- Back button restores previous scroll position
- Forward button restores appropriate position
- Page refresh starts at top
- Direct URL access starts at top
- Programmatic navigation (
goto) resets to top - Query parameter changes are handled correctly
- Snapshot persistence works across refreshes (if enabled)
- No memory leaks during long sessions
- Works on mobile devices with touch scrolling
References
- SvelteKit Navigation Documentation
- GitHub Issue #937: Customize navigation scroll container
- GitHub Issue #2733: Page scroll position not reset
- GitHub Issue #9914: Get access to scroll positions
- SvelteKit Snapshots Documentation
Future Considerations
- Performance Optimization: Implement a maximum size for the scroll positions Map to prevent memory issues in long sessions
- Animation Support: Consider smooth scrolling animations for certain navigation types
- Accessibility: Ensure screen readers properly announce page changes
- Analytics: Track scroll depth and navigation patterns for UX improvements
- Configuration: Consider making scroll behavior configurable per route