Skip to main content
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Kitt Hodsden
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Kitt Hodsden

Collapsing Sticky Elements And Maintaining Scroll Offsets In Angular 9.0.0-rc.2

By
Published in Comments (1)

One of the best user experience (UX) features that GitHub has added recently is the "stickiness" of the file-header in the Pull Request (PR) review mode. And, part of what makes this feature so enjoyable is the fact that as the user collapses the content of a given file, the Window maintains its scroll offset so that the user doesn't lose their place within the PR. A few months ago, I experimented with creating a Sticky Position component in Angular; and now, to continue my "sticky" learning, I wanted to see if I could replicate the GitHub user experience: maintaining the Window's scroll offset after the content associated with a sticky header is collapsed in Angular 9.0.0-rc.2.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

To understand the problem with naively collapsing content on a page, consider the following illustration that shows how the location of various elements on the page are changed when one of the elements is document-flow is collapsed:

When an element is collapsed, it brings up all the content below it in the browser window.

As you can see, when one of the elements in the document-flow is collapsed, the elements below it can suddenly be scrolled out-of-view as they floats up to fill-up the space created by the collapsed element. To create a better user experience (UX), what we want to do is both collapse the element and - at the same time - scroll the window up so that the remnants of the collapsed element are at the top of the browser viewport. This way, the content below the collapsed element remains in view.

To explore this concept in Angular 9, I'm going to try and re-create the GitHub PR experience, where the header of each section is sticky; and, upon clicking on the header, the body of the given section is collapsed:

// Import the core angular services.
import { Component } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

interface Section {
	title: string;
	content: string;
	isCollapsed: boolean;
}

@Component({
	selector: "app-root",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<ng-template ngFor let-section [ngForOf]="sections">

			<!--
				NOTE: A template variable (#sectionRef) is local to the current Template,
				which, in this case, is the ngFor template. That means that each
				iteration of the ngFor loop will get its own unique copy of #sectionRef.
			-->
			<section #sectionRef class="section">
				<header class="section__header">
					<a
						(click)="toggleSection( section, sectionRef )"
						class="section__toggle">
						{{ section.title }}
					</a>
				</header>
				<div
					*ngIf="( ! section.isCollapsed )"
					class="section__body">
					{{ section.content }}
					{{ "And more sweet copy over here. " | repeatString:1000 }}
				</div>
			</section>

		</ng-template>
	`
})
export class AppComponent {

	public sections: Section[];

	// I initialize the app component.
	constructor() {

		this.sections = [
			{ title: "Section 1 Title", content: "Section 1 copy.", isCollapsed: false },
			{ title: "Section 2 Title", content: "Section 2 copy.", isCollapsed: false },
			{ title: "Section 3 Title", content: "Section 3 copy.", isCollapsed: false },
			{ title: "Section 4 Title", content: "Section 4 copy.", isCollapsed: false },
			{ title: "Section 5 Title", content: "Section 5 copy.", isCollapsed: false },
			{ title: "Section 6 Title", content: "Section 6 copy.", isCollapsed: false },
			{ title: "Section 7 Title", content: "Section 7 copy.", isCollapsed: false },
			{ title: "Section 8 Title", content: "Section 8 copy.", isCollapsed: false },
			{ title: "Section 9 Title", content: "Section 9 copy.", isCollapsed: false },
			{ title: "Section 10 Title", content: "Section 10 copy.", isCollapsed: false }
		];

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I toggle the given section's content body.
	public toggleSection(
		section: Section,
		sectionElement: HTMLElement
		) : void {

		section.isCollapsed = ! section.isCollapsed;

		// If we're collapsing the content of the given section, it may dramatically
		// change the page-offset for the user. As such, if the section is currently
		// located above the viewport, we want to SCROLL THE WINDOW UP to the top of
		// the section element such that the content below the collapsed element
		// remains visible to the user.
		if ( section.isCollapsed ) {

			var rect = sectionElement.getBoundingClientRect();

			// If the section element is ABOVE THE VIEWPORT, adjust the scroll.
			if ( rect.top < 0 ) {

				window.scrollBy( 0, ( rect.top - 4 ) );
				// NOTE: The (-4) is to adjust for the CSS styling of the page.

			}

		}

	}

}

The approach here is relatively straightforward. When the user clicks on the Section header, I pass the Element reference into the toggleSection() method. I then use this Element reference to figure out where the Section is rendered, relative to the Viewport (using .getBoundingClientRect()). And, if the Section is rendered above the Viewport's top-fold, I simply scroll the window up using the magnitude of said offset.

Now, if we run this Angular 9.0.0-rc.2 application in the browser and try to collapse a few sections, you'll notice that head Section header "sticks" to the top of the viewport when it is collapsed:

Scrolling the window up when an element is collapsed in order to maintain an enjoyable scroll-offset in Angular 9.0.0-rc.2.

As you can see, when we collapse "Section 1", the window is scrolled-up so that the newly-collapsed "Section 1" is at the top of the Browser's viewport. This leaves the content of "Section 2" just below it, firmly within the User's sight. This creates a much more enjoyable user experience (compared to having "Section 2" scroll-up past the top-fold of the Viewport).

This was a fun little code kata. I really appreciate the effort that the GitHub team has put into the Pull Review experience. And, I'm happy to be able to reproduce some of that "sticky position" magic with Angular 9.0.0-rc.2.

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

Reader Comments

15,848 Comments

@All,

As a quick follow-up to this, I wanted to explore a somewhat-related topic of maintaining the user's current "scroll offset" even when content is added above the viewport:

www.bennadel.com/blog/3724-maintaining-scroll-offsets-when-adding-content-above-the-user-s-viewport-in-angular-9-0-0-rc-2.htm

One awesome thing I discovered during this demo is that Chrome and Firefox actually do this for you automatically - woot woot! However, with Safari and Chrome, we actually have to step in an do this programmatically.

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