Disable Angular’s scroll position restoration for specific routes
I’ve been doing Angular development lately, and as with any single-page app, the “Let’s do everything in JS” approach breaks basic web functionality. Scroll handling is only one example.
If you’re looking for a way to alter URL query params without having Angular scroll to the top of the page, this post is for you.
But before we get to the problem, let’s recognize what browsers are good at — doing web things. If your site relies on server-rendered HTML you get well-working scroll handling out of the box.
If you link to an anchor element (/something#foo
), the browser scrolls to it. Do you hit the back button? The browser instantly shows the last page, thanks to the BF cache. And the page is even in the correct scroll position. Browsers are pretty good at handling websites. One might think they’re built for this.
Sometimes (often?) SPA frameworks break these standard web features. And of course, then there’s more JS added to fix and reimplement what broke.
Here’s Angular’s RouterModule
configured to fix scroll handling.
@NgModule({
imports: [RouterModule.forRoot(routes, {
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled'
})],
exports: [RouterModule]
})
export class AppRoutingModule { }
anchorScrolling
scrolls to anchors if they’re available, and scrollPositionRestoration
scrolls the previous page to its last position when hitting the back button. Ignoring the fact that there must be an array storing all navigation’s scroll positions to reapply them, this works reasonably well. Forward navigations, on the other hand, are scrolled to the top.
But could there be situations when you want to update the URL without scrolling around? You bet!
If you’re building a shop search and want to apply filters or sorting (?filter=shoes&order=asc
), chances are high that you just want to update the URL and keep the current scroll position. Or, like in my case, maybe you want to open a modal or sidebar, and it should have a URL so you can link to it (?sidebar=reviews
). If you’re rendering UI in JS, there are plenty of cases in which you just want to update the URL.
So I thought I could slap an attribute onto a [routerlink]
and call it a day.
<a
[routerLink]="[]"
[queryParams]="{sidebar: 'reviews', productId: productId}"
[scrollPositionRestoration]="false">
Show all reviews
a>
But now it gets fun. Let me repeat the previous fact: forward navigations are scrolled to the top. Always? Yes always (unless you work around the core router behavior).
Here’s the scroll logic from Angular 17
core if you’re curious.
private consumeScrollEvents() {
return this.transitions.events.subscribe((e) => {
if (!(e instanceof Scroll)) return;
if (e.position) {
if (this.options.scrollPositionRestoration === 'top') {
this.viewportScroller.scrollToPosition([0, 0]);
} else if (this.options.scrollPositionRestoration === 'enabled') {
this.viewportScroller.scrollToPosition(e.position);
}
} else {
if (e.anchor && this.options.anchorScrolling === 'enabled') {
this.viewportScroller.scrollToAnchor(e.anchor);
} else if (this.options.scrollPositionRestoration !== 'disabled') {
this.viewportScroller.scrollToPosition([0, 0]);
}
}
});
}
Discovering this Angular core code took me a few hours already, but I can’t be alone with this issue, can I? Of course not. Here’s the GitHub issue from 2018 asking for a way to temporarily disable scrollPositionRestoration
.
The proposed framework solution is what I have hoped for: allow setting scrollPositionRestoration
on each navigation. But no one seemed to have PR’ed, reacted or cared about the issue.
I tried a few solutions and here are the ones that worked for me:
- Disable scroll position restoration entirely and roll your own scroll handling. Ufff.
- Link to a fragment that doesn’t exist and trick Angular into not scrolling. Also, ufff.
I was almost leaning into the ugly fragment hack but asked a colleague for a rubber duck session. And looking at the Angular core code, he came up with a dynamic JavaScript getter for the scrollPositionRestoration
option.
RouterModule.forRoot(routes, {
get scrollPositionRestoration() {
const params = new URLSearchParams(window.location.search);
if (params.get('sidebar')) {
return 'disabled' as const;
}
return 'enabled' as const;
},
scrollOffset: [0, 164],
anchorScrolling: 'enabled',
});
Whenever Angular updates the URL and checks if it should scroll around by accessing this
, the current URL is checked. If it includes a query param that shouldn’t trigger scrolling (sidebar
), scrollPositionRestoration
returns disabled
. Otherwise, it’ll be enabled
, and Angular will do its “scroll magic”.
Is this a perfect solution? I doubt it. Will there be edge cases where this approach leads to bugs? Most likely. Does it do the trick for me right now? Absolutely, because it’s way better than rolling my own scroll handling or adding ugly URLs to the app.