Sticky navigation bar after scroll using RxJS and Angular

Reading Time: 6 minutes

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

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.

let's go

Create a new Angular project

ng generate application day24-sticky-nav

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
];

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 { }
// app.module.ts

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

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

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 { }
// 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 { }

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

StickyNavPageComponent acts like a shell that encloses StickyNavHeaderComponent and StickyNavContentComponent. For your information, 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 {}

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);
  }
}

shouldFixNav$ is a boolean Observable, it resolves and toggles the fixed-nav CSS class of the