Skip to main content
Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.

Hiding Overflow Scrollbars Until Hover (ala Gmail Labels) In Angular 9.1.7

By Ben Nadel on

The other day, when using my Gmail account, I noticed an interesting little user experience (UX): my list of "labels", which is longer than my browser's viewport, is only scrollable when I mouse-over it. I assume this is done to hide the scrollbar which would otherwise create a strong "visual hurdle" in the middle of the page. Many years ago, I looked at something similar - keeping overflow content unresponsive until Clicked (with jQuery); as such, I thought it might be fun to try and mimic this Gmail behavior in Angular 9.1.7.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

CAUTION: I think there are non-trivial user experience (UX) issues when you hide a scrollbar. For one, in some browsers (such as Chrome), there's no way to CMD+F and locate the content within an overflow:hidden portion of the UI (User Interface). Also, depending on the design of the page, a scrollbar may be the only clear indication that a portion of the view is scrollable. As such, removing the scrollbar may confuse the user.

This is one of those cases where there is clear tension between accessibility and aesthetics. It is obvious that always showing the scrollbar leads to a more accessible, more intuitive experience. However, from a strictly-aesthetic standpoint, leaving a scrollbar in the "middle" of your viewport is less attractive. There are no winners here - both approaches are a trade-off.

Upon inspecting the DOM (Document Object Model) of my Gmail app, I could see that their implementation of the scrollable area included an inner-container with a fixed-width. This inner-container's fixed-width allowed for the scrollbar (on the parent element) to show-up without affecting the width of the content:

An illustration of the Gmail labels DOM that leaves a gap between the inner-wrapper and the host element (16 pixels wide).

By including the 16px gap between the inner-container and the host element, adding a scrollbar (as long as it's not wider than 16px) will leave the width of the inner-container unaffected. This is important because it creates a smoother experience, removing the need for content to shift around in response to the user's interactions.

It would be easy enough to hard-code the width of the inner-container in a real-world application. But, since this is an exploration, I thought it would be more interesting to try and create an Angular Component that would manage the nested-DOM structure and the width for us.

Plus, this feels like the perfect use-case for content projection.

To explore this idea in Angular 9.1.7, I created an App component that lists a bunch of Arnold Schwarzenegger movies. The same list of movies is repeated 4-times. The first two lists are wrapped in one version of my solution (app-scroll-on-mouseenter); the second two lists are wrapped in a second version of my solution (app-scroll-on-mouseenter2):

<div class="lists">
	<app-scroll-on-mouseenter class="list-viewport">

		<ul class="list">
			<li *ngFor="let movie of movies" class="item">
				{{ movie }}
			</li>
		</ul>

	</app-scroll-on-mouseenter>
	<app-scroll-on-mouseenter class="list-viewport">

		<ul class="list">
			<li *ngFor="let movie of movies" class="item">
				{{ movie }}
			</li>
		</ul>

	</app-scroll-on-mouseenter>

	<!-- *********** VERSION 2 of Directive Below This. *********** -->

	<app-scroll-on-mouseenter2 class="list-viewport">

		<ul class="list">
			<li *ngFor="let movie of movies" class="item">
				{{ movie }}
			</li>
		</ul>

	</app-scroll-on-mouseenter2>
	<app-scroll-on-mouseenter2 class="list-viewport">

		<ul class="list">
			<li *ngFor="let movie of movies" class="item">
				{{ movie }}
			</li>
		</ul>

	</app-scroll-on-mouseenter2>
</div>

With my first attempt - <app-scroll-on-mouseenter> - I tried to keep it super simple! I thought that I could just use the :hover pseudo-selector to adjust the width of the inner-container:

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

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

@Component({
	selector: "app-scroll-on-mouseenter",
	styles: [
		`
			:host {
				display: block ;
				overflow: hidden ;
			}
			.wrapper {
				width: calc( 100% - 16px ) ;
			}

			:host:hover {
				overflow: auto ;
				overscroll-behavior: contain ;
			}
			:host:hover .wrapper {
				width: 100% ;
			}
		`
	],
	template:
	`
		<div class="wrapper">
			<ng-content></ng-content>
		</div>
	`
})
export class ScrollOnMouseenterComponent {
	// ....
}

As you can see, this Angular component has almost no logic. It's just performing some content projection (transcluding the list of movies into the .wrapper element); and then, adjusting the overflow and width CSS properties when the user mouses-over the host element.

At first, this approach actually appeared to work quite nicely! However, as I was noodling on it, I had two realizations:

  1. The width of the scrollbar on my Mac doesn't actually take up 16px of space. It takes of 15px of space. As such, when I change the width of the inner-container from calc(100%-16px) to 100%, the manifested width actually changes by 1px. This causes a jarring effect with the text-overflow as the location of several ellipses shift, creating a visual distraction.

  2. Even more problematic is that some people have completely insane scrollbar settings (no judgment!) where they want the MacOS to hide their scrollbar always. This means that when I activate scrolling, and change the width of the inner-container to 100%, the manifested width increases by 16px, which is even more jarring than the text-overflow issue.

This latter experience can be seen in the following GIF:

Content jumping around when the inner-container width is changed with MacOS settings that always hide scrollbars.

As you can see, by using the [terrible] setting in your MacOS to always hide the scrollbar leads to an unintuitive, non-discoverable UI that - at least in this case - is also jarring as the content shifts around.

To deal with these different user-preferences, I created a second version of the directive that includes a bit more logic. With this second Angular component, rather than working exclusively with :hover, I am switching over to (mouseenter) and (mouseleave) event-handlers that set an explicit, pixel-based width on the inner-container.

The initial, non-scrollable width is still calc(100%-16px). However, when the user mouses into the component, I'm setting an inline style on the inner-container that is set to the result of the calculated width:

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

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

@Component({
	selector: "app-scroll-on-mouseenter2",
	host: {
		"[class.is-scrollable]": "isScrollable",
		"(mouseenter)": "enable()",
		"(mouseleave)": "disable()"
	},
	styles: [
		`
			:host {
				display: block ;
				overflow: hidden ;
			}
			.wrapper {
				width: calc( 100% - 16px ) ;
			}

			:host( .is-scrollable ) {
				overflow: auto ;
				overscroll-behavior: contain ;
			}
		`
	],
	template:
	`
		<div class="wrapper" [style.width.px]="wrapperWidth">
			<ng-content></ng-content>
		</div>
	`
})
export class ScrollOnMouseenter2Component {
	
	public isScrollable: boolean;
	public wrapperWidth: number | null;

	private elementRef: ElementRef;

	// I initialize the scroll-on-mouseenter component.
	constructor( elementRef: ElementRef ) {

		this.elementRef = elementRef;

		this.isScrollable = false;
		this.wrapperWidth = null;

	}

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

	// I disable scrolling on the host component.
	public disable() : void {

		this.isScrollable = false;
		// NOTE: By setting the CSS property to NULL, the browser will REMOVE the inline
		// style from the wrapper, allowing the stylesheet properties to take precedence.
		this.wrapperWidth = null;

	}


	// I enable scrolling on the host component.
	public enable() : void {

		this.isScrollable = true;
		// At this point (while the host element is still deactivated - our view-model
		// has not yet been reconciled with the component template), the width of the
		// wrapper is "calc( 100% - 16px )". When we activate the host element, the
		// scrollbars MAY OR MAY NOT APPEAR (depending on the user's MacOS settings). As
		// such, we have to switch from a calculated width to an EXACT WIDTH in order to
		// prevent the content from jumping. To do this, we're going to set an INLINE
		// style width that is equal to the current calc()-based width.
		this.wrapperWidth = this.elementRef.nativeElement
			.childNodes[ 0 ]
			.clientWidth
		;

	}

}

As you can see, when the user mouses-into the host element, we're setting a transient inline style that defines the width of the inner-container to be the .clientWidth of the inner-container. This allows us to translate the calc()width into an explicit px width.

With this second approach, the inner-container width doesn't bounce around even with the different MacOS settings:

Content does not jump around when the inner-container width is changed with MacOS settings that always hide scrollbars.

As you can see, with this second approach that applies an explicit, pixel-based width to the inner-container on mouse-enter, the content remains in-place regardless of the user's scrollbar settings.

As I said before, hiding scrollbars has non-trivial user experience (UX) issues. But, littering the user interface (UI) with scrollbars can lead to less attractive designs. As such, there is always going to be tension between accessibility and design. That said, wrapping this behavior in an Angular component was a fun exploration.

Epilogue On Gmail's Implementation

I noticed in the Gmail implementation that they are using min-width and max-width CSS properties on their inner-container, rather than setting an explicit width:

max-width: 240px ;
min-width: 240px ;

People at Google are pretty smart. So, I assume there is a critical difference between this approach and just setting a width:240px property. But, my CSS insights can't explain it. If anyone understands this pattern, I would love to hear it!



Reader Comments

@Emm,

Thank you for the link about min-width / max-width. Unfortunately, the answers go a little over my head :D I see they have an example with display: flex where it does have a difference. But, I think that's only when they leave-in the default flex property, which defaults to something like flex: 1 1 0. However, if you add in a value like flex: 0 0 auto, then the effect changes completely.

.... I'm mostly just talking out-loud here because clearly I don't have the strongest understanding of why Flexbox is behaving this way. I'll have to try and build a better mental model for how flex-basis works.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Blog
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.