Sticky navigation bar after scroll using RxJS and Angular

Connie Leung - Jan 26 '23 - - Dev Community

Introduction

This is day 23 of Wes Bos's JavaScript 30 challenge and I am going to use RxJS and Angular to create a sticky navigation bar after window scrolls past the header. When the header is not in the viewport, I use RxJS behaviorSubject to update boolean flag to add CSS class to HTML element of child components. The CSS class creates sticky navigation bar in one child component and transforms the <div> container of another one.

In this blog post, I describe how to use RxJS fromEvent to listen to window scroll event, update inline style of body element and the behaviorSubject in StickyNav service. Angular components then observe the behaviorSubject, resolve the Observable in the inline template and toggle CSS class in ngClass property.

Create a new Angular project

ng generate application day24-sticky-nav
Enter fullscreen mode Exit fullscreen mode

Add window service to listen to scroll event

In order to detect scrolling on native Window, I write a window service to inject to ScrollComponent to listen to scroll event. The sample code is from Brian Love's blog post here.

// core/services/window.service.ts

import { isPlatformBrowser } from "@angular/common";
import { ClassProvider, FactoryProvider, InjectionToken, PLATFORM_ID } from '@angular/core';

/* Create a new injection token for injecting the window into a component. */
export const WINDOW = new InjectionToken('WindowToken');

/* Define abstract class for obtaining reference to the global window object. */
export abstract class WindowRef {
  get nativeWindow(): Window | Object {
    throw new Error('Not implemented.');
  }
}

/* Define class that implements the abstract class and returns the native window object. */
export class BrowserWindowRef extends WindowRef {

  constructor() {
    super();
  }

  override get nativeWindow(): Object | Window {
    return window;    
  }
}

/* Create an factory function that returns the native window object. */
export function windowFactory(browserWindowRef: BrowserWindowRef, platformId: Object): Window | Object {
  if (isPlatformBrowser(platformId)) {
    return browserWindowRef.nativeWindow;
  }
  return new Object();
}

/* Create a injectable provider for the WindowRef token that uses the BrowserWindowRef class. */
const browserWindowProvider: ClassProvider = {
  provide: WindowRef,
  useClass: BrowserWindowRef
};

/* Create an injectable provider that uses the windowFactory function for returning the native window object. */
const windowProvider: FactoryProvider = {
  provide: WINDOW,
  useFactory: windowFactory,
  deps: [ WindowRef, PLATFORM_ID ]
};

/* Create an array of providers. */
export const WINDOW_PROVIDERS = [
  browserWindowProvider,
  windowProvider
];
Enter fullscreen mode Exit fullscreen mode

Then, we provide WINDOW injection token in CoreModule and import CoreModule to AppModule.

// core.module.ts

import { NgModule } from '@angular/core';

import { CommonModule } from '@angular/common';
import { WINDOW_PROVIDERS } from './services/window.service';

@NgModule({
  declarations: [],
  imports: [
    CommonModule
  ],
  providers: [WINDOW_PROVIDERS]
})
export class CoreModule { }
Enter fullscreen mode Exit fullscreen mode
// app.module.ts

... other import statements ...
import { CoreModule } from './core';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ... other imports ...
    CoreModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

Create StickyNav feature module

First, we create a StickyNav feature module and import it into AppModule. The feature module encapsulates StickyNavPageComponent, StickyNavHeaderComponent and StickyNavContentComponent.

Import StickyNavModule in AppModule

// sticky-nav.module.ts

import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { StickyNavContentComponent } from './sticky-nav-content/sticky-nav-content.component';
import { StickyNavHeaderComponent } from './sticky-nav-header/sticky-nav-header.component';
import { StickyNavPageComponent } from './sticky-nav-page/sticky-nav-page.component';

@NgModule({
  declarations: [
    StickyNavPageComponent,
    StickyNavHeaderComponent,
    StickyNavContentComponent
  ],
  imports: [
    CommonModule
  ],
  exports: [
    StickyNavPageComponent
  ]
})
export class StickyNavModule { }
Enter fullscreen mode Exit fullscreen mode
// app.module.ts

import { AppComponent } from './app.component';
import { CoreModule } from './core';
import { StickyNavModule } from './sticky-nav';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    StickyNavModule,
    CoreModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

Declare Sticky navigation bar components in feature module

In StickyNav feature module, we declare three Angular components, StickyNavPageComponent, StickyNavHeaderComponent and StickyNavContentComponent to build the application.

src/app
├── app.component.ts
├── app.module.ts
├── core
│   ├── core.module.ts
│   ├── index.ts
│   └── services
│       └── window.service.ts
└── sticky-nav
    ├── index.ts
    ├── services
    │   └── sticky-nav.service.ts
    ├── sticky-nav-content
    │   └── sticky-nav-content.component.ts
    ├── sticky-nav-header
    │   └── sticky-nav-header.component.ts
    ├── sticky-nav-page
    │   └── sticky-nav-page.component.ts
    └── sticky-nav.module.ts
Enter fullscreen mode Exit fullscreen mode

StickyNavPageComponent acts like a shell that encloses StickyNavHeaderComponent and StickyNavContentComponent. For your information, <app-stick-nav-page> is the tag of StickyNavPageComponent.

// sticky-nav-page.component.ts

import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'app-sticky-nav-page',
  template: `
    <ng-container>
      <app-sticky-nav-header></app-sticky-nav-header>
      <app-sticky-nav-content></app-sticky-nav-content>
    </ng-container>
  `,
  styles: [`
    :host {
      display: block;
    }
  `],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class StickyNavPageComponent {}
Enter fullscreen mode Exit fullscreen mode

StickyNavHeaderComponent encapsulates a header image and a menu that turns into a sticky navigation bar. StickyNavContentComponent is consisted of several paragraphs and random images.

// sticky-nav-header.component.ts

import { ChangeDetectionStrategy, Component, ElementRef, Inject, OnInit, ViewChild } from '@angular/core';
import { Observable, of } from 'rxjs';
import { WINDOW } from '../../core';
import { StickyNavService } from '../services/sticky-nav.service';

@Component({
  selector: 'app-sticky-nav-header',
  template: `
    <header>
      <h1>A story about getting lost.</h1>
    </header>
    <nav id="main" #menu [ngClass]="{ 'fixed-nav': shouldFixNav$ | async }">
      <ul>
        <li class="logo"><a href="#">LOST.</a></li>
        <li><a href="#">Home</a></li>
        <li><a href="#">About</a></li>
        <li><a href="#">Images</a></li>
        <li><a href="#">Locations</a></li>
        <li><a href="#">Maps</a></li>
      </ul>
    </nav>
  `,
  styles: [`
    :host {
      display: block;
    }

    ... omitted due to brevity ...

    nav.fixed-nav {
      position: fixed;
      box-shadow: 0 5px 0 rgba(0,0,0,0.1);
    }

    .fixed-nav li.logo {
      max-width: 500px;
    }
  `],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class StickNavHeaderComponent implements OnInit {
  @ViewChild('menu', { static: true, read: ElementRef })
  nav!: ElementRef<HTMLElement>;

  shouldFixNav$!: Observable<boolean>;

  constructor(@Inject(WINDOW) private window: Window, private service: StickyNavService) { }

  ngOnInit(): void {
    this.shouldFixNav$ = of(true);
  }
}
Enter fullscreen mode Exit fullscreen mode

shouldFixNav$ is a boolean Observable, it resolves and toggles the fixed-nav CSS class of the <nav> element. When the class is found in the element , header image disappears and the navigation bar is positioned at the top of the page.

// sticky-nav-content.component.ts

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { StickyNavService } from '../services/sticky-nav.service';

@Component({
  selector: 'app-sticky-nav-content',
  template: `
    <div class="site-wrap" [ngClass]="{ 'fixed-nav': shouldFixNav$ | async }">
      ... paragraphs and images are omitted due to brevity...
    </div>
  `,
  styles: [`
    :host {
      display: block;
    }

    .fixed-nav.site-wrap {
      transform: scale(1);
      border: 2px solid black;
    }

    ... omitted due to brevity ...
  `],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class StickNavContentComponent {
  shouldFixNav$ = this.service.shouldFixNav$;

  constructor(private service: StickyNavService) { }
}
Enter fullscreen mode Exit fullscreen mode

Similar to StickyNavHeaderComponent, StickNavContentComponent performs scale transformation and adds border around the <div> container when the container includes fixed-nav CSS class.

// sticky-nav.service.ts

import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class StickyNavService {
  private readonly shouldFixNavSub = new BehaviorSubject<boolean>(false);
  readonly shouldFixNav$ = this.shouldFixNavSub.asObservable();

  addClass(value: boolean) {
    this.shouldFixNavSub.next(value);
  }
}
Enter fullscreen mode Exit fullscreen mode

StickNavContentComponent injects StickyNavService service to the constructor to share the boolean Observable. StickyNavService creates a new Observable using shouldFixNavSub as the source. The inline template resolves the Observable in order to toggle the CSS class.

Next, I delete boilerplate codes in AppComponent and render StickyNavPageComponent in inline template.

// app.component.ts

import { Component } from '@angular/core';
import { Title } from '@angular/platform-browser';

@Component({
  selector: 'app-root',
  template: '<app-sticky-nav-page></app-sticky-nav-page>',
  styles: [`
    :host {
      display: block;
    }
  `]
})
export class AppComponent {
  title = 'Day 24 Sticky Nav';

  constructor(titleService: Title) {
    titleService.setTitle(this.title);
  }
}
Enter fullscreen mode Exit fullscreen mode

Use RxJS and Angular to implement StickyNavHeaderComponent

I am going to rewrite shouldFixNav$ and update the BehaviorSubject in tap.

Use ViewChild to obtain reference to nav element

// stick-nav-header.component.ts

@ViewChild('menu', { static: true, read: ElementRef })
nav!: ElementRef<HTMLElement>;

ngOnInit(): void {
    const navNative = this.nav.nativeElement;
    const body = navNative.closest('body');

    this.shouldFixNav$ = fromEvent(this.window, 'scroll')
      .pipe(
        map(() => this.window.scrollY > navNative.offsetTop),
        tap((result) => {
          if (body) {
            body.style.paddingTop = result ? `${navNative.offsetHeight}px` : '0';
          }            
          this.service.addClass(result);
        }),
        startWith(false)
      );
}
Enter fullscreen mode Exit fullscreen mode
  • fromEvent(this.window, ‘scroll’) – listens to scroll event of the window
  • map(() => this.window.scrollY > navNative.offsetTop) – determines whether or not the navigation bar is out of the viewport
  • tap() – update paddingTop property of the body element and the BehaviorSubject in StickyNavService
  • startWith(false) – emits the initial value of the Observable

Final Thoughts

In this post, I show how to use RxJS and Angular to create a sticky navigation bar. fromEvent observes window scrolling to detect when the navigation bar is not in the viewport. When it occurs, the Observable emits true to add the fixed-nav class to HTML elements. Otherwise, the Observable emits false to remove the class and revert the sticky effect.

The header component communicates with the shared service to toggle the value of the BehaviorSubject. Sibling content component injects the service to obtain the Observable to toggle the class of the div container.

This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.

Resources:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .