When navigating between routes, @esmx/router automatically manages scroll position to match user expectations. Pushing to a new page scrolls to the top; going back restores the previous scroll position. This mirrors how traditional multi-page websites behave.
The router handles scrolling differently based on the navigation type:
push: Scrolls to top (0, 0)replace: Scrolls to top (0, 0)back: Restores saved scroll positionforward: Restores saved scroll positiongo(n): Restores saved scroll positionpushWindow: Handled by browserreplaceWindow: Handled by browserawait router.push('/new-page');
await router.back();This works out of the box with no configuration needed.
When leaving a page (via push, replace, or history navigation), the router saves the current scroll position using two mechanisms:
Map<string, ScrollPosition> keyed by the page's full URLhistory.state under the __scroll_position_key propertyconst scrollPosition = { left: window.scrollX, top: window.scrollY };
scrollPositions.set(currentUrl, scrollPosition);
history.replaceState({
...history.state,
__scroll_position_key: scrollPosition
}, '');Storing in history.state means scroll positions survive page refreshes — when the user refreshes and then navigates back, the correct scroll position can still be restored.
The router sets history.scrollRestoration = 'manual' automatically. This tells the browser not to attempt its own scroll restoration, leaving full control to the router.
This is configured during the confirm phase of navigation — you don't need to set it yourself.
Sometimes you don't want navigation to scroll to the top. For example, when switching tabs or filtering content, the user expects to stay where they are:
await router.push({
path: '/dashboard',
query: { tab: 'settings' },
keepScrollPosition: true
});When keepScrollPosition is set to true:
__keepScrollPosition flag is stored in history.stateThis flag is also checked during back/forward navigation — if the target history entry was created with keepScrollPosition: true, scroll restoration is skipped.
The scroll system supports scrolling to a specific element on the page using a CSS selector:
import { scrollToPosition } from '@esmx/router';
scrollToPosition({ el: '#section-title' });
scrollToPosition({
el: '#section-title',
top: -80,
behavior: 'smooth'
});
const element = document.querySelector('.target');
scrollToPosition({ el: element });The el property accepts:
'#my-id', '.my-class', '[data-section]')Element reference
If you need to scroll to an element after navigation, use the afterEach hook:
router.afterEach((to) => {
if (to.hash) {
setTimeout(() => {
scrollToPosition({ el: to.hash });
}, 100); // wait for DOM to update
}
});Routes opened as layers (via pushLayer or createLayer) skip scroll handling entirely. Since layers render in an overlay on top of the current page, scrolling the background page would be disruptive:
await router.pushLayer('/confirm-dialog');This behavior is built into the router's confirm phase — scroll logic is skipped when router.isLayer is true.
Here's the complete flow of how scroll is handled during different navigation types:
1. Save current scroll position for the current URL
2. Perform navigation (update history, mount component)
3. Scroll to (0, 0) — unless keepScrollPosition is true1. Save current scroll position for the current URL
2. Perform navigation (history popstate fires)
3. Wait for DOM update (nextTick)
4. Check if history.state has __keepScrollPosition flag
→ If yes: skip scroll restoration
→ If no: restore saved scroll position for the new URL
→ Falls back to (0, 0) if no saved position exists1. Full browser navigation — scroll handled by browser nativelykeepScrollPosition: true to disable.'manual'). Set automatically by router.history.state. Automatic.