Skip to main content
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Mike Collins and Elishia Dvorak
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Mike Collins ( @collinsmikej ) Elishia Dvorak ( @elishtweet )

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

By 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.

Want to use code from this post? Check out the license.

Reader Comments

426 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!

426 Comments

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){ 
            ...
        }

    }

)
15,688 Comments

@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.

2 Comments

hi,
I want to use this code in a project that is Ionic
But it only works on the

tag. And it doesn't work with Ionic tags

15,688 Comments

@Marisa,

That's interesting. I don't have any Ionic experience myself. But, I am not sure why it wouldn't work with Ionic. I assume Ionic uses custom elements, just like I am; so, that shouldn't be the issue. Could it be that the browser you are testing on doesn't actually support the IntersectionObserver API?

1 Comments

Hi. Ben. Thank for the article. It is very easy to follow and you have provide plenty of code to work it though.
Ben. Just wandering is this code going to work for Angular 9 since Ivy is suppose to be zone.js less?

15,688 Comments

@Peter,

I believe that Angular 9 does still have Zone.js. I have heard that future versions may get rid of it in lieu of something more explicitly (like React's setState() method). But, I think that's still on the roadmap, not yet implemented. As such, this approach should continue to work, at least for the near future.

That said, I'm glad you found this post interesting :D

15,688 Comments

@All,

Based on something I heard on (what I think was) the Shop Talk Show / CSS Tricks podcast, I wanted to re-examine this type of workflow using a top: -1px:

www.bennadel.com/blog/3932-using-a-top-of-1px-to-observe-position-sticky-intersection-changes-in-angular-11-0-3.htm

The idea in this new post is very similar to the one in this older post: we're using the IntersectionObserver API to look for an overlap with the top-edge of the viewport. Only, instead of using content-projection and tracking pixels, a top: -1pxallows us to use the host element as the point of intersection.

It doesn't always work (if someone is scrolling very fast); but, it is definitely a simplified approach.

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel