Anchor scrolling in Angular

ayyash

Ayyash

Posted on November 22, 2022

Anchor scrolling in Angular

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]);
}
Enter fullscreen mode Exit fullscreen mode

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]);
      }
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
           });
         }
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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});
  })
);
Enter fullscreen mode Exit fullscreen mode

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],
})
Enter fullscreen mode Exit fullscreen mode

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});
}
Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
ayyash
Ayyash

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