Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Nathan Deneau
Ben Nadel at cf.Objective() 2013 (Bloomington, MN) with: Nathan Deneau

Creating A "position: sticky" Header Component Using IntersectionObserver In Angular 7.2.11

By Ben Nadel on

When I recently got around to trying out the CSS property "position: sticky" for the first time, I was quite shocked at how magical it was. Magical in the sense that it "just worked"; and, that it worked in exactly the way that I would have wanted it to work. It's kind of amusing to think about how much JavaScript has been replaced by this one little CSS property. That said, one thing that it doesn't do is provide a way to detect when a target element has entered or exited the "sticky state." As such, I wanted to see if I could encapsulate the "position: sticky" concept inside of an Angular 7.2.11 component in such a way that a CSS class could be conditionally added or removed based on the current stickiness of the component.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

Position: sticky provides web developers with a way to get an element to stick to an edge of the viewport (either the browser's screen or an overflow container) while the parent element overlaps with the same edge of the viewport. Essentially, it temporarily turns the sticky element from a relatively-positioned element into fixed-positioned element as the scroll-offset of the viewport changes.

In order to tap into that temporary state change, I'm going to take inspiration from the article, "An event for CSS position:sticky" by Eric Bidelman. In his article, Eric uses the IntersectionObserver in order to asynchronously observe intersection-changes to elements that surround the sticky element. And, by seeing when surrounding elements move into and out of the designated viewport, we are able to [roughly] deduce the state of the sticky element itself.

Before we look at how this is done, let's first look at the template for my App component. I won't bother showing the code-behind it as it is neither interesting nor all that relevant. The key points to see in the following template are that we are using a custom Angular component, "<my-sticky-header>", in order to encapsulate the concept of "position: sticky". And, that this component provides an input binding - [stickyClass] - that allows us to define a CSS class to apply when the component is stuck to the edge of the viewport:

  • <h2>
  • A Rather Sticky Exploration
  • </h2>
  •  
  • <section
  • *ngFor="let section of sections"
  • [id]="section.id"
  • class="section">
  •  
  • <!--
  • The Sticky-Header will automatically apply "position: sticky" to itself. And,
  • will add the [stickyClass] CSS class to itself whenever the header enters the
  • "sticky" state. This can be used to alter the visual styling of the header when
  • it is stuck to the top of the Viewport.
  • -->
  • <my-sticky-header
  • class="section__header"
  • stickyClass="section__header--stuck">
  •  
  • <h3 class="section__title">
  • {{ section.title }}
  • </h3>
  •  
  • <a
  • href="#{{ section.id }}"
  • (click)="toggleSection( section )"
  • class="section__toggle"
  • [ngSwitch]="section.isCollapsed">
  •  
  • <ng-template [ngSwitchCase]="false"> Collapse </ng-template>
  • <ng-template [ngSwitchCase]="true"> Expand </ng-template>
  • </a>
  •  
  • </my-sticky-header>
  •  
  • <div *ngIf="( ! section.isCollapsed )" class="section__body">
  •  
  • <p *ngFor="let i of [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]">
  • This is the body of the section ....
  • </p>
  •  
  • </div>
  •  
  • </section>

In this case, the conditional CSS class - "section__header--stuck" - will change the background-color of the header and add a slight box-shadow when the element enters the "stuck" state (ie, when it is fixed to the edge of the viewport).

Now, let's look at the StickyHeaderComponent to see how it is using the IntersectionObserver internally to deduce the state of the host element. First, let's look at the component's template:

  • <span #topMarkerRef class="top-marker"></span>
  • <span #bottomMarkerRef class="bottom-marker"></span>
  •  
  • <ng-content></ng-content>

As you can see, this component template is just a thin wrapper around the projected content in the calling context. But, it adds two "marker" element - one that is positioned "-1px" from the top of the host element; and, one that is positioned "-1px" from the bottom of the host element. You can see the LESS CSS for these elements here:

  • :host {
  • display: block ;
  • position: relative ;
  •  
  • // Only apply sticky override if supported by the browser.
  • @supports ( position: sticky ) or ( position: -webkit-sticky ) {
  • position: sticky ;
  • position: -webkit-sticky ;
  • top: 0px ;
  • }
  • }
  •  
  • .top-marker,
  • .bottom-marker {
  • height: 0px ;
  • left: 0px ;
  • position: absolute ;
  • width: 0px ;
  • }
  •  
  • // The "markers" are positioned just outside of the sticky-header. This allows the
  • // intersection of these markers with the Viewport to help us determine when the header
  • // element is stuck to the top of the Viewport.
  • .top-marker {
  • top: -1px ;
  • }
  •  
  • .bottom-marker {
  • bottom: -1px ;
  • }

By placing these two markers just outside the bounds (more or less) of the host element, we can use the intersection of these markers in order to determine when the host element is touching the edge of the viewport. It's almost like a mini state-machine:

  • TopMarker: visible
  • BottomMarker: visible
  • Stuck: false
  • TopMarker: visible
  • BottomMarker: hidden
  • Stuck: false
  • TopMarker: hidden
  • BottomMarker: visible
  • Stuck: true
  • TopMarker: hidden
  • BottomMarker: hidden
  • Stuck: false

As you can see, we know that the host element must be stuck to the top edge of the viewport when the top marker is hidden and the bottom marker is still visible.

And, now that you understand the basic algorithm, let's look at the StickyHeaderComponent code to see how the IntersectionObserver is being used. Note that we're binding the IntersectionObserver outside of the Angular zone as we only want to trigger change-detection when the CSS class is conditionally added or removed to and from the host element.

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  • import { ElementRef } from "@angular/core";
  • import { NgZone } from "@angular/core";
  • import { ViewChild } from "@angular/core";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Component({
  • selector: "my-sticky-header",
  • inputs: [ "stickyClass" ],
  • queries: {
  • topMarkerRef: new ViewChild( "topMarkerRef" ),
  • bottomMarkerRef: new ViewChild( "bottomMarkerRef" )
  • },
  • styleUrls: [ "./sticky-header.component.less" ],
  • template:
  • `
  • <span #topMarkerRef class="top-marker"></span>
  • <span #bottomMarkerRef class="bottom-marker"></span>
  •  
  • <ng-content></ng-content>
  • `
  • })
  • export class StickyHeaderComponent {
  •  
  • public bottomMarkerRef!: ElementRef;
  • public isStuck: boolean;
  • public stickyClass!: string | undefined;
  • public topMarkerRef!: ElementRef;
  •  
  • private elementRef: ElementRef;
  • private isBottomMarkerVisible: boolean;
  • private isTopMarkerVisible: boolean;
  • private observer: IntersectionObserver | null;
  • private zone: NgZone;
  •  
  • // I initialize the sticky-header component.
  • constructor(
  • elementRef: ElementRef,
  • zone: NgZone
  • ) {
  •  
  • this.elementRef = elementRef;
  • this.zone = zone;
  •  
  • this.isBottomMarkerVisible = false;
  • this.isStuck = false;
  • this.isTopMarkerVisible = false;
  • this.observer = null;
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I get called once when the component is being destroyed.
  • public ngOnDestroy() : void {
  •  
  • if ( this.observer ) {
  •  
  • this.observer.disconnect();
  • this.observer = null;
  •  
  • }
  •  
  • }
  •  
  •  
  • // I get called once after the inputs have been bound for the first time.
  • public ngOnInit() : void {
  •  
  • // If the browser doesn't support "position: sticky" or doesn't support the
  • // IntersectionObserver, let's short-circuit the initialization of the sticky
  • // header component. This will allow the component to degrade gracefully.
  • if ( ! this.supportsStickyPosition() || ! IntersectionObserver ) {
  •  
  • return;
  •  
  • }
  •  
  • // If the browser supports "position: sticky", we're going to start watching for
  • // changes in DOM-state. In order to limit the amount of change-detection that
  • // Angular will run, let's configure the IntersectionObserver outside of the core
  • // NgZone. Then, we can re-enter the zone when public state has to change.
  • this.zone.runOutsideAngular(
  • () => {
  •  
  • this.observer = new IntersectionObserver( this.handleIntersection );
  • this.observer.observe( this.bottomMarkerRef.nativeElement );
  • this.observer.observe( this.topMarkerRef.nativeElement );
  •  
  • }
  • );
  •  
  • }
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  • // I handle changes to the intersection of the observed elements.
  • private handleIntersection = ( entries: IntersectionObserverEntry[] ) : void => {
  •  
  • var previousIsStuck = this.isStuck;
  • var nextIsStuck = this.isStuck;
  •  
  • for ( var entry of entries ) {
  •  
  • if ( entry.target === this.bottomMarkerRef.nativeElement ) {
  •  
  • this.isBottomMarkerVisible = entry.isIntersecting;
  •  
  • }
  •  
  • if ( entry.target === this.topMarkerRef.nativeElement ) {
  •  
  • this.isTopMarkerVisible = entry.isIntersecting;
  •  
  • }
  •  
  • }
  •  
  • // Since we know that the "sticky-header" component will only stick to the top of
  • // the Viewport with "top: 0px", we know that the header can be considered stuck
  • // if the bottom marker is visible and the top marker is not. This would place
  • // the top-edge of the header along the top-edge of the Viewport.
  • // --
  • // CAUTION: This is a rough calculation and does not account for border styling
  • // that may be applied to the header by the calling-context.
  • nextIsStuck = ( this.isBottomMarkerVisible && ! this.isTopMarkerVisible );
  •  
  • // If the overall "stickiness" of the header hasn't changed, just return out.
  • // While this callback may have changed private properties, nothing public has
  • // changed. As such, we don't have to worry about triggering change-detection.
  • if ( nextIsStuck === previousIsStuck ) {
  •  
  • return;
  •  
  • }
  •  
  • // If the stickiness of the header has changed (either entering or exiting the
  • // sticky state), we are going to change the public state of the component. As
  • // such, we need to dip back into the core Zone so that these changes will be
  • // picked up by the change-detection digest.
  • this.zone.run(
  • () => {
  •  
  • ( this.isStuck = nextIsStuck )
  • ? this.elementRef.nativeElement.classList.add( this.stickyClass )
  • : this.elementRef.nativeElement.classList.remove( this.stickyClass )
  • ;
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I determine if the browser supports "position: sticky".
  • private supportsStickyPosition() : boolean {
  •  
  • if ( ! CSS.supports ) {
  •  
  • return( false );
  •  
  • }
  •  
  • return(
  • CSS.supports( "position", "sticky" ) ||
  • CSS.supports( "position", "-webkit-sticky" )
  • );
  •  
  • }
  •  
  • }

As you can see, we're keeping track of the visibility of each marker element. And, when the combination of the Top and Bottom markers changes the deduced state of the host element, we're dropping back into the Angular zone in order to trigger change-detection.

Now, if we run this in the browser and scroll down through the page, we get the following output:


 
 
 

 
 Creating a position: sticky header component in Angular 7.2.11. 
 
 
 

As you can see, when the top marker is hidden (off the top of the viewport) and the bottom marker is still visible on the page, we are deducing that the host element is in a "stuck" state. At that time, our StickyHeaderComponent is conditionally adding the [stickyClass] CSS class.

By default, the IntersectinObserver uses the browser's screen as the viewport. So, if you wanted to get the host element to stick to an "overflow container" instead, you'd have to update the input bindings to accept an optional ElementRef that we could provide as a configuration option to the IntersectionObserver constructor. Ultimately, however, the algorithm would remain the same.

It's taken me a long time to get around to trying out the "position: sticky" CSS property. But, I'm happy that I finally did because it offers a lot of power with next-to-no effort. And, it's nice to see that there are ways to tap into the state-change of the sticky element so that we can conditionally change the styling of the element when it becomes fixed within the viewport.



Reader Comments

Ben. This looks super great. I want to try and implement this effect in my latest Angular project, where I am displaying adverts. Unfortunately, your demo does not work in mobile Safari, although I know for a fact that webkit supports:

position: sticky;

When I view the following demo on my iPhone 8:

http://html5-demos.appspot.com/static/css/sticky.html

Everything works, so maybe your:

supportsStickyPosition()

Is not evaluating correctly?

I am only going to use it on the mobile version of my project, because, firstly, my mobile layout uses block elements, which will accentuate the sticky effect. And secondly, I need to be able to use the entire viewport height to pull off the illusion.
Once the intersection observer kicks in, I will add a sticky position inner drop shadow DIV at the top the advert area and fixed position inner drop shadow DIV at the bottom of the screen. The parent element, containing the advert, will scroll through this, giving the illusion that advert is scrolling underneath the main content area. The illusion should hold on both the upward & downward scroll.

Anyway, thanks, once again, for providing the inspiration and a starting point!

Once, it is done, I will send you a link!

Reply to this Comment

Ben. Just one other thing. With:

this.zone.runOutsideAngular

Can I still use @Input variables inside this block? Will Angular still detect any external change to this variable?

Like:

@Input isFoo: boolean;

this.zone.runOutsideAngular(  () => {

        if(this.isFoo){ 
            ...
        }

    }

)
Reply to this Comment

@Charles,

I think the issue on mobile Safari might be the lack of IntersectionObserver. I think that might only be available in the latest release. I know on Twitter, you indicated that a recent updated made the demo work for you? I suspect that is why.

As far as the zone.runOutsideAngular(), you can definitely access anything inside that you would be able to access anything outside. So, in your case, you can certainly access this.isFoo since your ()=>{} callback keeps the this binding.

Really, the only thing that runOutsideAngular() is doing is making sure event-bindings are hooked-up outside the automatic change-detection mechanism. So, you can think of everything working exactly the same .... only, some things won't trigger change detection until you dip back into the Angular zone via a subsequent .run().

You could also manually trigger a change-detection using the ChangeDetectorRef, and it would work as well.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.