The RouterScroller is one of those components of the Angular Router that often flies under the radar, yet it powers several behaviors developers rely on daily. This article breaks down how it operates, what enables its functionality, and the configuration options available to tailor it to specific requirements.
It makes possible features such as scrolling to a fragment, adjusting an offset for that fragment, and restoring the scroll position when the user navigates via the browser's back or forward buttons (triggered by a popstate event).
The setup process for RouterScroller
Relevant source files: router_module, router_scroller.
Because @angular/router ships as part of the Angular framework, it must undergo an initialization sequence to ensure proper configuration. This happens within a listener registered for the APP_BOOTSTRAP_LISTENER event. Inspecting the source, the initial lines of the function consist of this.injector.get calls:
const opts = this.injector.get(ROUTER_CONFIGURATION);
const preloader = this.injector.get(RouterPreloader);
const routerScroller = this.injector.get(RouterScroller);
const router = this.injector.get(Router);
const ref = this.injector.get<ApplicationRef>(ApplicationRef);
/* ... */
At first glance, this might seem straightforward, but several of the arguments passed to this.injector.get are actually factory tokens. That means a specific piece of logic executes to produce the requested value.
Take RouterScroller—the subject of this discussion—as an example. It is provided in the following manner:
{
provide: RouterScroller,
useFactory: createRouterScroller,
deps: [Router, ViewportScroller, ROUTER_CONFIGURATION]
},
The createRouterScroller factory is responsible for instantiating the RouterScroller class, using the configuration from the ROUTER_CONFIGURATION token. This is evident in the class constructor:
constructor(
private router: Router,
public readonly viewportScroller: ViewportScroller, private options: {
scrollPositionRestoration?: 'disabled'|'enabled'|'top',
anchorScrolling?: 'disabled'|'enabled'
} = {}) {
// Default both options to 'disabled'
options.scrollPositionRestoration = options.scrollPositionRestoration || 'disabled';
options.anchorScrolling = options.anchorScrolling || 'disabled';
}
Once the Router has been initialized (through Router.initialNavigation), the RouterScroller is also initialized via RouterScroller.init():
init(): void {
// we want to disable the automatic scrolling because having two places
// responsible for scrolling results race conditions, especially given
// that browser don't implement this behavior consistently
if (this.options.scrollPositionRestoration !== 'disabled') {
this.viewportScroller.setHistoryScrollRestoration('manual');
}
this.routerEventsSubscription = this.createScrollEvents();
this.scrollEventsSubscription = this.consumeScrollEvents();
}
The final two lines capture the essence of RouterScroller. We will delve into these in the upcoming section, supported by practical examples.
The inner workings of RouterScroller
Router events—like NavigationStart and NavigationEnd—are central to how RouterScroller achieves its goals. It generates Scroll events based on these router events.
private lastId = 0;
/* ... */
private createScrollEvents() {
return this.router.events.subscribe(e => {
if (e instanceof NavigationStart) {
// store the scroll position of the current stable navigations.
this.store[this.lastId] = this.viewportScroller.getScrollPosition();
this.lastSource = e.navigationTrigger;
this.restoredId = e.restoredState ? e.restoredState.navigationId : 0;
} else if (e instanceof NavigationEnd) {
this.lastId = e.id;
this.scheduleScrollEvent(e, this.router.parseUrl(e.urlAfterRedirects).fragment);
}
});
}
In a browser environment, ViewportScroller.getScrollPosition() returns [this.window.scrollX, this.window.scrollY];—a tuple indicating the current horizontal and vertical scroll offsets. The property e.navigationTrigger reveals what caused the navigation, which can be one of three values:
'imperative'– triggered manually viarouter.navigate()orrouter.navigateByUrl()'popstate'– triggered by the forward or back button, or byhistory.back()/history.forward()'hashchange'– triggered when thefragment(the part after the#) changes
The e.restoredState property is particularly intriguing. Let's picture the browser's history stack. Each call to history.pushState(stateObj, title, url) adds a new entry to that stack, which essentially marks the completion of a router navigation. Angular tags every navigation with a unique navigationId (also used for this.restoredId). The navigationId is attached to a new navigation in this way:
const id = ++this.navigationId;
// `setTransition` will push the object through the `Router stream` - a series of operators that have to deal with
// determining the router configuration object, running the guards, setting the browser's URL etc...
this.setTransition({
id,
source,
restoredState,
currentUrlTree: this.currentUrlTree,
currentRawUrl: this.rawUrlTree,
rawUrl,
extras,
resolve,
reject,
promise,
currentSnapshot: this.routerState.snapshot,
currentRouterState: this.routerState
});
When a popstate event fires, the browser moves to the second topmost entry in the stack, which, as noted, carries a navigationId in its state. That navigationId becomes this.restoredId. Thus, when a NavigationStart event occurs, two key actions take place:
- the current scroll position (
scrollXandscrollY) is saved (tracked vialastId) - the
restoredIdis determined (if available), representing thenavigationIdof the prior navigation; note this only happens forpopstateevents
Conversely, when a NavigationEnd event fires, only the current navigationId is recorded: this.lastId = e.id;, followed by the creation of a Scroll event:
private scheduleScrollEvent(routerEvent: NavigationEnd, anchor: string|null): void {
// `router.triggerEvent` will push a new event(`routerEvent`) through the `router.events` stream
this.router.triggerEvent(new Scroll(
// so, only if the `popstate` event occurred, the stored position will considered
// otherwise, the position will be `null`
routerEvent, this.lastSource === 'popstate' ? this.store[this.restoredId] : null, anchor));
}
Now let's examine how these Scroll events are processed:
// from `init()` method
this.scrollEventsSubscription = this.consumeScrollEvents();
/* ... */
private consumeScrollEvents() {
return this.router.events.subscribe(e => {
if (!(e instanceof Scroll)) return;
// a popstate event. The pop state event will always ignore anchor scrolling.
if (e.position) {
if (this.options.scrollPositionRestoration === 'top') {
this.viewportScroller.scrollToPosition([0, 0]);
} else if (this.options.scrollPositionRestoration === 'enabled') {
this.viewportScroller.scrollToPosition(e.position);
}
// imperative navigation "forward"
} else {
if (e.anchor && this.options.anchorScrolling === 'enabled') {
this.viewportScroller.scrollToAnchor(e.anchor);
} else if (this.options.scrollPositionRestoration !== 'disabled') {
this.viewportScroller.scrollToPosition([0, 0]);
}
}
});
}
In a browser, viewportScroller.scrollToPosition is essentially: this.window.scrollTo(position[0] /* x */, position[1] /* y */); The behavior of this handler can be modified through configuration options supplied when setting up the RouterModule (via RouterModule.forRoot(routes, { /* config */ })). We'll look at a few of these options next.
Before diving into that, let's clarify the concepts by walking through a typical user scenario.
Suppose a user follows this sequence:
- clicks on
go to default - clicks on
go to default/foo - scrolls until
Test from foo - bottom 20%is in view, along withgo to default/bar - clicks on
go to default/bar
Now, clicking the go back button should bring you directly to the Test from foo - bottom 20% text:

Here's a visual representation of the flow:

x{n}, y{n} denote the scrollX and scrollY values at the moment a new navigation begins.
After clicking go back, the RouterScroller's internal state looks like this:
{
restoredId: 2, // `navigationId`
lastId: 2
/* ... */
}
Using store[restoredId], we retrieve the scroll coordinates for the previous /default/foo view.
Note: this demo uses the scrollPositionRestoration option: RouterModule.forRoot(routes, { scrollPositionRestoration: 'enabled' })
Router options affecting RouterScroller
We've already seen what scrollPositionRestoration accomplishes in the previous section.
Two more valuable options are anchorScrolling and scrollOffset:
private consumeScrollEvents() {
return this.router.events.subscribe(e => {
if (!(e instanceof Scroll)) return;
// a popstate event. The pop state event will always ignore anchor scrolling.
if (e.position) {
if (this.options.scrollPositionRestoration === 'top') {
this.viewportScroller.scrollToPosition([0, 0]);
} else if (this.options.scrollPositionRestoration === 'enabled') {
this.viewportScroller.scrollToPosition(e.position);
}
// imperative navigation "forward"
} else {
if (e.anchor && this.options.anchorScrolling === 'enabled') {
this.viewportScroller.scrollToAnchor(e.anchor);
} else if (this.options.scrollPositionRestoration !== 'disabled') {
this.viewportScroller.scrollToPosition([0, 0]);
}
}
});
}
As shown above, setting anchorScrolling to enabled allows navigation to a specific element within the new view. The target element is identified by its id attribute:
<h4 id="custom-fragment"><!-- ... --></h4>
and the navigation is performed with:
<button routerLink="/default/foo" fragment="custom-fragment"><!-- ... --></button>
When anchorScrolling is active, the scrollOffset option can also be handy. You can experiment with it using: RouterModule.forRoot(routes, { scrollOffset: [xOffset, yOffset] }). To put it simply, think of a Cartesian coordinate system where Y represents the point the browser would scroll to without any offset. The default offset is 0. The calculation is: finalOffsetY = Y + yOffset, with the same logic applying to the X coordinate.
Wrapping up
The RouterScroller delivers core functionality within angular/router. We've covered its setup, the mechanisms behind its operations, and several configuration options to adapt it to different needs.
Thank you for reading!
