Using A Top Of -1px To Observe Position Sticky Intersection Changes In Angular 11.0.3
Last year, I explored the idea of creating a position: sticky
header component in Angular 7. This component used content protection and some "tracking pixels" to figure out when a position: sticky
element was actually "stuck" to the edge of the viewport. This worked, but was a bit complicated. The other week, I was listening to - what I think - was Dave Rupert on the Shop Talk Show podcast, who mentioned that you can greatly simplify this approach by using a top: -1px
on the sticky element so that you can easily observe the stuckness of the element based on its overlap with the browser viewport. This sounds so much easier than what I had done; so, I wanted to try it out in Angular 11.0.3.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
When you specify that an element is position: sticky
, you also have to provide an edge offset. This can be top
, bottom
, left
or right
; but, in the vast majority of most cases, this tends to be top
. And, in any case, this edge offset determines the location of the sticky element relative to the overflow container when the element moves into a "stuck" state. So, for example, if you had the following CSS:
position: sticky ;
top: 10px ;
... the host element would transition to, what is essentially, a position: fixed
with a top: 10px
when the host element becomes stuck to the top of the overflow container.
So, Dave's suggestion was to set the top
value to -1px
so that when the host element moves into a "stuck" state, the very top edge of the element is just above the viewport while the vast majority of the host element is within the viewport:
With this configuration, we can then use the IntersectionObserver
API to change the state of the element when the top-edge is above the viewport and the bottom-edge is within the viewport. Or, in other words, when the host element is overlapping the top of the viewport (thanks to the top:-1px
).
To experiment with this in Angular 11.0.3, I created a simple App component with the following HTML:
<ng-template ngFor let-h [ngForOf]="[1,2,3,4,5]">
<section>
<!--
NOTE: Our [stickyClass] attribute is BOTH an INPUT for the CSS class to apply
as well as the SELECTOR that targets our Angular Directive.
-->
<header
class="header"
stickyClass="header--sticky">
<h3>
I am sticky header {{ h }}.
</h3>
</header>
<p *ngFor="let i of [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]">
This is some body text {{ i }}.
</p>
</section>
</ng-template>
Within each section
, there is a position: sticky
header
element. Each header
element is going to use the selector, [stickyClass]
, to both target an Angular Directive and to provide the CSS class that should be applied to the host element when the element moves into a "stuck" state.
The LESS CSS for this HTML markup uses the top: -1px
as we discussed above:
:host {
display: block ;
}
.header {
background-color: #ffffff ;
padding: 17px 10px 16px 10px ;
position: sticky ;
// When using "position:sticky", you have to provide a TOP value (in our case since
// we want it to stick to the top of the overflow container) or the browser won't
// know when to change the rendering behavior of the element. By using a value of -1,
// it means that the "sticky" behavior will cause the element to SLIGHTLY OVERLAP
// with the top-edge of the browser's viewport. And that means, we can OBSERVE that
// change with the Intersection Observer API.
top: -1px ;
&--sticky {
background-color: #666666 ;
border-bottom: 1px solid #ffffff ;
box-shadow: 0px 6px 6px -3px fade( #000000, 40% ) ;
color: #ffffff ;
left: 0px ;
padding-left: 20px ;
padding-right: 20px ;
right: 0px ;
transition-duration: 500ms ;
transition-property: background-color, color, padding ;
transition-timing-function: ease ;
}
h3 {
margin: 0px 0px 0px 0px ;
}
}
And now, the Angular directive - [stickyClass]
- that uses the IntersectionObserver
API to detect when that host element has moved into or out of a "stuck" state:
// Import the core angular services.
import { Directive } from "@angular/core";
import { ElementRef } from "@angular/core";
import { NgZone } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Directive({
selector: "[stickyClass]",
inputs: [ "stickyClass" ]
})
export class StickyClassDirective {
public stickyClass!: string;
private elementRef: ElementRef;
private observer: IntersectionObserver | null;
private zone: NgZone;
// I initialize the sticky class directive.
constructor(
elementRef: ElementRef,
zone: NgZone
) {
this.elementRef = elementRef;
this.zone = zone;
this.observer = null;
}
// ---
// PUBLIC METHODS.
// ---
// I get called once when the host element is being destroyed.
public ngOnDestroy() : void {
this.observer?.disconnect();
this.observer = null;
}
// I get called once after the inputs have been bound for the first time.
public ngOnInit() : void {
// Since the intersection events won't change any view-model (in this demo),
// there's no need to trigger any change-detection digests. As such, we can bind
// the interaction observer callback outside of the Angular Zone.
this.zone.runOutsideAngular(
() => {
// By using threshold values of both 0 and 1, we will observe a change
// when even 1px of the host element passes into the viewport as well as
// when the entire element moves out of the viewport.
this.observer = new IntersectionObserver(
this.handleIntersection,
{
threshold: [ 0, 1 ]
}
);
this.observer.observe( this.elementRef.nativeElement );
}
);
}
// ---
// PRIVATE METHODS.
// ---
// I handle changes in the observed intersections of the targets.
private handleIntersection = ( entries: IntersectionObserverEntry[] ) => {
for ( var entry of entries ) {
// CAUTION: Since we know that the TOP specified in the "sticky"
// configuration of our CSS class has a "-1px" value, then when the element's
// rendering behavior switches from "static" to "sticky", the bounding client
// rectangle will place the TOP of the element at ABOVE THE VIEWPORT (at
// about -1px). As such, any time the top of the bounding client rectangle is
// less than zero (while the BOTTOM is still visible), we can consider the
// element to be "sticky" / "stuck".
if (
( entry.boundingClientRect.top < 0 ) &&
( entry.boundingClientRect.bottom > 0 )
) {
this.elementRef.nativeElement.classList.add( this.stickyClass );
} else {
this.elementRef.nativeElement.classList.remove( this.stickyClass );
}
}
};
}
When we setup the IntersectionObserver
API, notice that we're using the following threshold:
threshold: [ 0, 1 ]
The thresholds are a bit confusing to me; but, what I think is going on here is the following:
0
- Allows us to observe when even one pixel of the host element crosses into or out of the viewport.1
- Allows us to observe when the entire host element crosses into or out of the viewport.
Again, the meaning of the thresholds isn't 100% clear in my head; but, I definitely needed both in order to get this to work. And, whenever the IntersectionObserver
callback is invoked, I'm looking at the bounding client rectangle to see if it overlapping with the edge of the viewport. That is when the top edge is less than zero and the bottom edge is greater than zero.
When the host element overlaps with the top edge, I add the stickyClass
; and, when it's not overlapping, I remove the stickyClass
. And, when we run this in the browser and scroll down, we get the following output:
As you can see, the header
element is receiving the header--sticky
CSS class as we scroll down the page!
Ultimately, it would just be awesome if the browser's would add some sort of :stuck
CSS pseudo-selector. This would just make our lives way easier. But, until that happens, we have to bridge the gap with JavaScript. And, the IntersectionObserver
API is a pretty good bridge for adding a sticky class to our overlapping elements in Angular 11.0.3.
Edge-Case Epilogue: It Doesn't Always Work
This is pretty cool, and technically fairly straightforward; however, it doesn't always work. Since we're using the IntersectionObserver
API and reacting to changes in the host element's interaction with the viewport, there is an edge-case in which our callback won't fire: if the host element is observed as overlapping with the bottom of the viewport; and then, on next observation is seen to be overlapping with the top of the viewport.
In this case, if the user is scrolling very fast (perhaps by using the Space Bar), then the "intersection" doesn't actually change - it just shifts the relative edge of the intersection. Meaning, the host element is "partially intersecting" in both successive checks - it just jumped immediately from a bottom intersection to a top intersection. Because of this, the callback doesn't fire on the second check and we never end-up adding the CSS class.
To counteract this edge-case, we could do something like add a setTimeout()
and re-check the .getBoundingClientRect()
for the entry
in a future tick of the event loop. Or, we could just go back to using a more robust Angular component. Or, there might be a solution here that I'm not even seeing yet.
Or, we might just accept that there's an edge case that doesn't work very well and call it a day. It really depends on how bad the UI looks when the CSS class doesn't get applied.
Want to use code from this post? Check out the license.
Reader Comments