Anchor scrolling in Angular
Ayyash
Posted on November 22, 2022
If you've ever run into issues of scrolling the body into the right position when a fragment is included in the URL, then stick around.
Anchor clicking in SPAs.
The fragment is the hash
property of the URL, usually pointing to an anchor on the page. Anchor clicking to be working on web needs the following basics:
<a href="#anchor">Click me</a>
and in another location
<element id="anchor"></element>
For that to work while the page is loaded, nothing else is needed. In Angular, worth mentioning, the link should be a router link with a fragment
property, like this:
<a routerLink="." fragment="anchor">Click me</a>
According to Angular documentation, we need to set the anchorScrolling
property of the root router to enabled
for the scrolling to happen. But that isn't quite as accurate.
Then what does this property exactly do? It calls a function scrollToAnchor
when a scrolling event is caught with anchor
. Let's dive into the function. The function makes few checks, finds the element, then gets coordinates like this:
// Angular internal function common library
scrollToElement(el) {
const rect = el.getBoundingClientRect();
const left = rect.left + this.window.pageXOffset;
const top = rect.top + this.window.pageYOffset;
const offset = this.offset();
this.window.scrollTo(left - offset[0], top - offset[1]);
}
Now then, why --- even though it finds the element --- the returned values are all zeros?
If the element is outside the main component (thus gets loaded faster than any child component) it might be found, but it will appear at the very top. So zeros is the correct reading. If however it is inside a child component, the returned object to scroll to might be null
if it has not yet loaded.
We have to wait for the client to load. But how long does it take?
Let me share the scroll event "consumer" function found in RouterModule
Angular code, for reference, because we are going to override this with our own subscriber:
// Angular internal code in router module library
private consumeScrollEvents() {
return this.router.events.subscribe(e => {
if (!(e instanceof Scroll)) return;
// a popstate event. The pop state event will always ignore anchor scrollin
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') {
/*********** here is where things happen ************/
this.viewportScroller.scrollToAnchor(e.anchor);
} else if (this.options.scrollPositionRestoration !== 'disabled') {
this.viewportScroller.scrollToPosition([0, 0]);
}
}
});
}
Timer
The first and most obvious way to solve this is a timeout
before scrolling to element. If we set anchorScrolling
to enabled
in root route module, the Scroll
event is triggered immediately after NavigationEnd
. In version 15 however, the event is triggered with a timeout (See release fix):
// Angular internal code
private scheduleScrollEvent(routerEvent: NavigationEnd, anchor: string|null): void {
this.zone.runOutsideAngular(() => {
setTimeout(() => {
this.zone.run(() => {
this.router.triggerEvent(new Scroll(
routerEvent, this.lastSource === 'popstate' ? this.store[this.restoredId] : null,
anchor));
});
}, 0);
});
}
But here is the thing, the timer triggers the event almost immediately (0 milliseconds). That is still not what we are looking for, because sometimes the content takes a bit of a turn before it is loaded (think headless CSM, prerendering does not fix this problem because Angular kicks in when JavaScript is ready). So let's write our own event subscriber, with at least 1 second delay.
The final code is on StackBlitz
In our root routing module, we will set up an event listener and call scrollToAnchor
function after timeout. We also should disable any anchorScrolling
so that it won't run the built in code.
// root routing module
@NgModule({
imports: [
RouterModule.forRoot(routes, {
// you may still do this
scrollPositionRestoration: 'top',
// but don't do this, the default is disabled
// anchorScrolling: 'enabled'
})
],
exports: [RouterModule]
})
export class AppRoutingModule {
constructor(
router: Router,
viewportScroller: ViewportScroller,
) {
// listen to events
router.events.pipe(
// catch the Scroll event
filter(event => event instanceof Scroll)
).subscribe({
next: (e: Scroll) => {
if (e.anchor) {
// timeout for 1 second before scrolling
// or timer(1000) RxJS
zone.runOutsideAngular(() => {
setTimeout(() => {
zone.run(() => {
viewportScroller.scrollToAnchor(e.anchor);
});
}, 1000);
});
}
}
});
}
}
We can use the out-of-zone method Angular dudes use, to look cool. 😎
You can fine tune the timer to what suits your project, and remember you need enough distance from the page load, but fast enough not to confuse the user.
That is not ideal. Can we do better?
Scroll on specific events
If all we need is a simple page with headers then waiting for 1 second should be fine. But if we have a complex setup of headless CSM with sub titles that we want linked, then we want to be a bit creative. Here is my suggestion:
Trigger a scroll event without affecting the history state. I still want to use the internal mechanism of Angular library, I do not wish to explicitly calculate the position, nor timeout if I don't have to. There are two places we can do this:
- after the end of every HTTP call
- selectively after returned HTML binding finishes
Since triggering a scroll event is only possible through firing a navigation event, then our solution is around navigate
method.
So let's make an assumption, of an HTTP call, that ends in a bit of milliseconds:
// example call to an http function
this.post$ = this.postService.GetPost('someparams').pipe(
// on finish, try to navigate
finalize(() => {
// router = Router
// url has the hash with it
// skipLocationChange so that it does not build up in history
// for this to work, the root router module needs to be set to
// onSameUrlNavigation: 'reload'
this.router.navigateByUrl(this.router.url, {skipLocationChange: true});
})
);
This is also coupled with anchorScrolling
as enabled
, and onSameUrlNavigation
as reload
. We might also consider setting scrollOffset
to a value away from the edge, like 200px
:
// root routing module, for navigate solution to work
RouterModule.forRoot(routes, {
onSameUrlNavigation: 'reload',
anchorScrolling: 'enabled',
scrollOffset: [0, 200],
})
This can be designed better, if we had a service and an HTTP interceptor. But I am not going to confuse you with my setup, just remember, on finalizing an HTTP interceptor, in a good application, there is usually another event that takes place: hide page loader. You might want to place this line along with it.
Don't worry, navigatgeByUrl
does not reload the component, in fact, reloading the component is a dream that almost never comes true.
To find out if the URL has a fragment, we can use the ActivatedRoute
snapshot property:
this.activatedRoute.snapshot.fragment
Or simply just find the #
in your router.url
this.router.url.indexOf('#') > -1
So the basic couple of lines that we need to design are:
// we need to run this after the event we think creates and populates our HTML
if (this.router.url.indexOf('#') > -1) {
this.router.navigateByUrl(this.router.url, {skipLocationChange: true});
}
Personally, I use Sanity headless CSM, right after I render HTML, I call a function to process the HTML beyond the original formatting. And I use the out-of-zone timeout to look cool. 😎
Note: about SSR, the generated anchors are detected by SEO crawlers just fine.
Thank you for bearing with me. Did you catch the bear in that?
RESOURCES
Posted on November 22, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
August 31, 2024