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
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, <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 {}
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 <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) { }
}
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);
}
}
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);
}
}
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)
);
}
- 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:
- Github Repo: https://github.com/railsstudent/ng-rxjs-30/tree/main/projects/day24-sticky-nav
- Live demo: https://railsstudent.github.io/ng-rxjs-30/day24-sticky-nav/
- Wes Bos's JavaScript 30 Challenge: https://github.com/wesbos/JavaScript30