Copying Slack's Brilliant Virtual Scrollbar And Overflow Container In Angular 9.1.12
The other day, as I was using the Slack chat app at InVision, I happened to notice that the scrollbar in the Channels column is only visible when I'm mousing-over the Channels column. I recently explored the idea of conditionally showing scrollbars on hover
in Angular (based on GMail's implementation); however, Slack's scrollbars aren't just conditionally rendered, they also overlap with the viewport content - something that a native scrollbar (on Desktop) won't do. Clearly, something quite cleaver is going on. So, I opened up the browser-based version of Slack and took a look. And, what I saw was totally fascinating. So much so that I wanted to see if I could copy Slack's brilliant virtual scrollbar and overflow container implementation in Angular 9.1.12.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
What makes Slack's virtual scrollbar and overflow container so very cool is the fact that it's only partially virtual! In fact, the overflow container that houses Slack's list of Channels is a true overflow container complete with its own native scrollbar; only, Slack is hiding the native scrollbar and showing its own virtual scrollbar stacked over the viewport content.
The brilliance of this approach is that Slack doesn't need to re-implement all of the native scrollbar behaviors - the overflow container does a lot of the heavy lifting. As such, Slack only needs to re-implement the direct scrollbar interactions. This creates a much smoother, much more consistent experience over other virtual scrollbar solutions that attempt to re-implement the )entirety of an overflow-scroll behavior.
The key to making all of this work is the fact that the overflow container is wider than the viewport. This renders the native scrollbar outside the clipping area of the viewport while still maintaining all of the native behaviors provided by the overflow container:
Essentially, the overflow container still provides the user with the following native browser behaviors:
- Using
CMD+F
to find-and-scroll-to matched content. - Using
Space
to page down andSHIFT+Space
to page up. - Using
PageDown
to page down andPageUp
to page up. - Using
ArrowDown
to line down andArrowUp
to line up. - Using the mouse wheel to smoothly scroll the content.
It's just that, the user can't see the scrollbar in the overflow container as it is being hidden by the viewport's narrower width.
Of course, Slack still needs to show some sort of scrollbar for a good user experience (UX). As such, it stacks a virtual scrollbar over the content of the viewport. Slack then needs to implement some of the native scrollbar behaviors on top of the virtual scrollbar; but, the vast majority of the work is already being done by the browser.
The virtual scrollbar only needs to implement the following behaviors:
- Mirror the state of the overflow container (on
scroll
events). - Page up and down based on mouse clicks.
- Update the overflow container when the virtual scrollbar is dragged by the user.
These aren't trivial behaviors (especially the drag behavior); but, this is much less work than Slack would have had to do had they completely reinvented the wheel.
So now the fun part: trying to implement this dual native scrollbar / virtual scrollbar using an Angular 9.1.2 component. The first thing I did was set up my test environment, which is just a simple App component that generates a few hundred Slack channel names:
// Import the core angular services.
import { Component } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "app-root",
styleUrls: [ "./app.component.less" ],
templateUrl: "./app.component.html"
})
export class AppComponent {
public channels: string[];
// I initialize the app component.
constructor() {
this.channels = this.buildChannels( 200 );
}
// I log the do-check event so we can see if our virtual scrollbar triggered change-
// detection in the Angular view-model (it should not).
public ngDoCheck() : void {
console.log( "Do-check event triggered." );
}
// ---
// PRIVATE METHODS.
// ---
// I construct a channels array with the given number of items.
private buildChannels( channelCount: number ) : string[] {
var channels: string[] = [];
for ( var i = 0 ; i < channelCount ; i++ ) {
channels.push( `Generated Channel At Index ${ i + 1 }` );
}
return( channels );
}
}
These channels are then rendered using content projection and a custom Angular component:
<app-slack-scroller class="scroller">
<ul class="channels">
<li
*ngFor="let channel of channels"
class="channels__channel">
{{ channel }}
</li>
</ul>
</app-slack-scroller>
The <app-slack-scroller>
component is what is implementing the overflow container and the virtual scrollbar. And, when we run this in the browser, we get the following output:
As you can see, all of the native behaviors of the overflow container "just work" because we haven't disabled the overflow container in anyway - we've just hidden the scrollbar. This is why I can do all the keyboard navigation and the super smooth scrolling without any jank.
To implement this, here's my virtual scroller's HTML:
<div #viewportRef class="viewport">
<!--
The Viewport is going to have a scrollbar, which will change the width of the
content. As such, we have to wrap the content in an element that has an explicit
width that won't be affected by the scrollbar.
-->
<div #contentRef>
<ng-content></ng-content>
</div>
</div>
<!-- The scrollbar will be "stacked" over the viewport. -->
<div #scrollbarRef class="scrollbar">
<div #scrollbarThumbRef class="scrollbar__thumb">
<!-- -->
</div>
</div>
As you can see, we're using content projection to render the list of Slack channels inside an internal viewport. But, the .viewport
element is actually wider than the host element, which is what hides the native scrollbars from view:
This is done using a -50px
absolute positioning of the right
border:
:host {
border: 1px solid #999999 ;
display: block ;
height: 500px ;
overflow: hidden ;
overscroll-behavior: contain ;
position: relative ;
width: 200px ;
}
.viewport {
bottom: 0px ;
left: 0px ;
overflow-x: hidden ;
overflow-y: scroll ;
padding-right: 50px ;
position: absolute ;
right: -50px ; // --- THIS IS WHAT RENDERS THE SCROLLBAR OFF SCREEN. ---
top: 0px ;
}
.scrollbar {
bottom: 0px ;
opacity: 0 ;
position: absolute ;
right: 0px ;
top: 0px ;
transition: opacity 150ms ease ;
user-select: none ;
width: 20px ;
z-index: 2 ;
&__thumb {
height: 75px ;
left: 0px ;
position: absolute ;
top: 0px ;
width: 20px ;
&:before {
background-color: fade( #cccccc, 80% ) ;
border-radius: 20px 20px 20px 20px ;
bottom: 4px ;
content: "" ;
left: 6px ;
position: absolute ;
top: 4px ;
transition: background-color 150ms ease ;
width: 8px ;
}
}
&--dragging &__thumb:before {
background-color: #666666 ;
}
:host:hover &,
&--dragging {
opacity: 1 ;
}
}
Now that we see how the native scrollbar is being hidden via CSS, let's look at how the virtual scrollbar behavior is being implemented. For my approach, I am looking at the scroller as having three distinct states:
Passive State - This is the default state, wherein the scroller is just passively listening for the native
scroll
events; and then, updates the virtual scrollbar to mirror the scroll-offset of the overflow container.Paging State - This is when the user clicks on the virtual scrollbar, but not on the "thumb". This causes the overflow container to scroll up or down by one full-length of the viewport.
Dragging State - This is when the user clicks-and-drags on the virtual scrollbar thumb in order to drag the thumb up and down within the bounds of the scrollbar.
I've tried to include a lot of comments in my implementation, so I won't go into much more detail. I'll just provide the code as-is. One thing I will add, though, is that all of my event-bindings are being done inside the TypeScript, not in the view. I did this for two reason:
I wanted to be able to add and remove event-handlers as I transitioned between the aforementioned states.
I didn't want to trigger any change-detection digests in Angular as I was handling user-interaction events. Since my main goal here is to quietly re-implement some native browser behavior without affecting any view-models, I didn't want to burden the CPU with any unnecessary view-model reconciliation.
NOTE: To be clear, this is my implementation, not Slack's. I have no idea how Slack built theirs; and, I'm pretty sure they also include "virtual scrolling", where they dynamically add and remove content as the user scrolls. My implementation is "static".
// 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";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
// CAUTION: Not all browsers support "passive" event bindings. This can be done more
// gracefully with some feature-checks. But, for the sake of simplicity (since a
// scrolling demo is already quite complex), I'm only going to support browsers that
// support passive event bindings.
var PASSIVE = {
passive: true
};
@Component({
selector: "app-slack-scroller",
queries: {
contentRef: new ViewChild( "contentRef" ),
scrollbarRef: new ViewChild( "scrollbarRef" ),
scrollbarThumbRef: new ViewChild( "scrollbarThumbRef" ),
viewportRef: new ViewChild( "viewportRef" )
},
styleUrls: [ "./slack-scroller.component.less" ],
templateUrl: "./slack-scroller.component.html"
})
export class SlackScrollerComponent {
// DOM-Reference / view-child queries (to be injected by Angular).
public contentRef!: ElementRef;
public scrollbarRef!: ElementRef;
public scrollbarThumbRef!: ElementRef;
public viewportRef!: ElementRef;
private contentHeight: number;
private draggingStateViewportBottom: number;
private draggingStateViewportHeight: number;
private draggingStateViewportTop: number;
private hostRef: ElementRef;
private scrollbarHeight: number;
private scrollbarThumbHeight: number;
private scrollHeight: number;
private scrollPercentage: number;
private scrollTop: number;
private viewportHeight: number;
private zone: NgZone;
// I initialize the slack-scroller component.
constructor(
elementRef: ElementRef,
zone: NgZone
) {
this.hostRef = elementRef;
this.zone = zone;
this.contentHeight = 0;
this.draggingStateViewportBottom = 0;
this.draggingStateViewportHeight = 0;
this.draggingStateViewportTop = 0;
this.scrollbarHeight = 0;
this.scrollbarThumbHeight = 0;
this.scrollHeight = 0;
this.scrollPercentage = 0;
this.scrollTop = 0;
this.viewportHeight = 0;
}
// ---
// PUBLIC METHODS.
// ---
// I get called once after the view and its contents have been initialized.
public ngAfterViewInit() : void {
// After the view is initialized, we want to update the content-wrapper to be the
// same size as the host element so that the scrollbar for the viewport (which is
// hidden from view) doesn't affect the width of the projected-content.
this.contentRef.nativeElement.style.width = `${ this.hostRef.nativeElement.clientWidth }px`;
// Transition to our initial, passive state to listen from scrolling.
this.passiveStateSetup();
}
// I get called once when the component is being unmounted.
public ngOnDestroy() : void {
// I don't know what state we're in, so just destroy "all the things!"
this.passiveStateTeardown();
this.pagingStateTeardown();
this.draggingStateTeardown();
}
// ---
// PRIVATE METHODS.
// ---
// I calculate and store the scroll state of the content within the viewport. These
// values are then used in subsequent calculations.
// --
// CAUTION: This method gets CALLED A LOT - it's execution needs to be super fast!
private calculateViewportScrollPercentage() : void {
// Get short-hand references to the native DOM elements.
var viewportElement = this.viewportRef.nativeElement;
if ( this.scrollHeight ) {
this.scrollTop = viewportElement.scrollTop;
this.scrollPercentage = ( this.scrollTop / this.scrollHeight );
} else {
this.scrollTop = 0;
this.scrollPercentage = 0;
}
}
// I return a value that is constrained by the given min and max values.
private clamp( value: number, minValue: number, maxValue: number ) : number {
return( Math.min( Math.max( value, minValue ), maxValue ) );
}
// I handle mousemove events for the dragging-state.
// --
// CAUTION: Using FAT-ARROW FUNCTION to generate bound instance method.
private draggingStateHandleMousemove = ( event: MouseEvent ) : void => {
// Get short-hand references to the native DOM elements.
var viewportElement = this.viewportRef.nativeElement;
// Calculate the location of the mouse within the smaller, "meaningful viewport"
// that was determined at the beginning of the dragging-state. We'll need to then
// take this smaller value and translate it onto the larger, true viewport.
var clientY = this.clamp( event.clientY, this.draggingStateViewportTop, this.draggingStateViewportBottom );
var localOffset = ( clientY - this.draggingStateViewportTop );
var localOffsetPercentage = ( localOffset / this.draggingStateViewportHeight );
// Scroll the viewport to the calculated location and then update the thumb to
// match the viewport's state.
viewportElement.scrollTop = ( localOffsetPercentage * this.scrollHeight );
this.calculateViewportScrollPercentage();
this.updateThumbPositionToMatchScrollPercentage();
}
// I handle mouseup events for the dragging-state.
// --
// CAUTION: Using FAT-ARROW FUNCTION to generate bound instance method.
private draggingStateHandleMouseup = ( event: MouseEvent ) : void => {
// Transition to the passive state.
this.draggingStateTeardown();
this.passiveStateSetup();
}
// I setup the dragging-state, initializing state values and binding all state-
// specific event-handlers. The dragging-state moves the simulated scrollbar thumb
// alongside the user's mouse, and then updates the viewport offset to match the
// simulated scrollbar state.
private draggingStateSetup( event: MouseEvent ) : void {
// Get short-hand references to the native DOM elements.
var viewportElement = this.viewportRef.nativeElement;
var scrollbarElement = this.scrollbarRef.nativeElement;
var scrollbarThumbElement = this.scrollbarThumbRef.nativeElement;
// When the user clicks on the scrollbar-thumb, we need to use the mouse LOCATION
// and the scrollbar-thumb SIZE to translate the viewport into a SLIGHTLY SMALLER
// viewport. The reason for this is that we want the thumb's location to mirror
// the location of the mouse. To do this, we're going to need to get the rendered
// location of the viewport and the scrollbar thumb.
var viewportRect = viewportElement.getBoundingClientRect();
var scrollbarThumbRect = scrollbarThumbElement.getBoundingClientRect();
// Figure out how the initial mouse location splits the thumb element in half.
var initialY = event.clientY;
var thumbLocalY = ( initialY - scrollbarThumbRect.top );
// Now, reduce the "meaningful viewport" dimensions by the top-half and the
// bottom-half of the thumb. This way, the viewport will be fully-scrolled when
// the bottom of the thumb hits the bottom of scrollbar, even if the user's mouse
// hasn't fully-reached the bottom of the viewport.
this.draggingStateViewportTop = ( viewportRect.top + thumbLocalY );
this.draggingStateViewportBottom = ( viewportRect.bottom - scrollbarThumbRect.height + thumbLocalY );
this.draggingStateViewportHeight = ( this.draggingStateViewportBottom - this.draggingStateViewportTop );
// Always show the scrollbar while dragging, even if the user's mouse leaves the
// surface area of the viewport.
scrollbarElement.classList.add( "scrollbar--dragging" );
window.addEventListener( "mousemove", this.draggingStateHandleMousemove );
window.addEventListener( "mouseup", this.draggingStateHandleMouseup );
}
// I teardown the dragging-state, removing all state-specific event-handlers.
private draggingStateTeardown() : void {
// Get short-hand references to the native DOM elements.
var scrollbarElement = this.scrollbarRef.nativeElement;
scrollbarElement.classList.remove( "scrollbar--dragging" );
window.removeEventListener( "mousemove", this.draggingStateHandleMousemove );
window.removeEventListener( "mouseup", this.draggingStateHandleMouseup );
}
// I setup the paging-state, initializing state values and binding all state-
// specific event-handlers. The paging-state adjusts the viewport scroll offset by
// one page, either up or down, in the direction of the mouse.
private pagingStateSetup( event: MouseEvent ) : void {
// Get short-hand references to the native DOM elements.
var viewportElement = this.viewportRef.nativeElement;
var scrollbarElement = this.scrollbarRef.nativeElement;
var scrollbarThumbElement = this.scrollbarThumbRef.nativeElement;
// Get the viewport coordinates of the scrollbar thumb - we need to see if the
// user clicked ABOVE the thumb or BELOW the thumb.
var scrollbarThumbRect = scrollbarThumbElement.getBoundingClientRect();
// Scroll content UP by ONE PAGE.
if ( event.clientY < scrollbarThumbRect.top ) {
viewportElement.scrollTop = Math.max( 0, ( this.scrollTop - this.viewportHeight ) );
// Scroll content DOWN by ONE PAGE.
} else {
viewportElement.scrollTop = Math.min( this.scrollHeight, ( this.scrollTop + this.viewportHeight ) );
}
this.calculateViewportScrollPercentage();
this.updateThumbPositionToMatchScrollPercentage();
// Transition to the passive state.
// --
// TODO: In the future, we could set a timer to see if the user holds-down the
// mouse button, at which point we could continue to page the viewport towards
// the mouse cursor. However, for this exploration, we're going to stick to a
// single paging per mouse event.
this.pagingStateTeardown();
this.passiveStateSetup();
}
// I teardown the paging-state, removing all state-specific event-handlers.
private pagingStateTeardown() : void {
// Nothing to teardown for this state.
}
// I handle mousedown events for the passive-state.
// --
// CAUTION: Using FAT-ARROW FUNCTION to generate bound instance method.
private passiveStateHandleScrollbarMousedown = ( event: MouseEvent ) : void => {
// Get short-hand references to the native DOM elements.
var scrollbarThumbElement = this.scrollbarThumbRef.nativeElement;
// In order to prevent the user's click-and-drag gesture from highlighting a
// bunch of text on the page, we have to prevent the default behavior of the
// mousedown event.
event.preventDefault();
if ( event.target === scrollbarThumbElement ) {
// Transition to the dragging state (the user is going to drag the thumb to
// adjust scroll-offset of the viewport).
this.passiveStateTeardown();
this.draggingStateSetup( event );
} else {
// Transition to the paging state (the user is going to adjust the scroll-
// offset of the viewport by increments of the viewport height).
this.passiveStateTeardown();
this.pagingStateSetup( event );
}
}
// I handle scroll events for the passive-state.
// --
// CAUTION: Using FAT-ARROW FUNCTION to generate bound instance method.
private passiveStateHandleViewportScroll = ( event: MouseEvent ) : void => {
this.calculateViewportScrollPercentage();
this.updateThumbPositionToMatchScrollPercentage();
}
// I setup the passive-state, initializing state values and binding all state-
// specific event-handlers. The passive-state primarily listens for scroll events on
// the viewport and then updates the simulated scrollbar to match the location.
private passiveStateSetup() : void {
// Get short-hand references to the native DOM elements.
var viewportElement = this.viewportRef.nativeElement;
var scrollbarElement = this.scrollbarRef.nativeElement;
var scrollbarThumbElement = this.scrollbarThumbRef.nativeElement;
// For the sake of performance, we're going to calculate the dimensions of the
// viewport and other elements once at the start of the passive state; and then,
// only calculate scroll-percentages going forward (as we react to events).
this.viewportHeight = viewportElement.clientHeight;
this.contentHeight = viewportElement.scrollHeight;
this.scrollHeight = ( this.contentHeight - this.viewportHeight );
this.scrollbarHeight = scrollbarElement.clientHeight;
this.scrollbarThumbHeight = scrollbarThumbElement.clientHeight;
// Since these are the initial state's event handlers, it should create a
// cascading effect wherein every subsequent event handler is bound outside of
// the Angular Zone. This should prevent any of the event handlers contained
// within this component from triggering change-detection in the Angular app.
this.zone.runOutsideAngular(
() => {
this.viewportRef.nativeElement.addEventListener( "scroll", this.passiveStateHandleViewportScroll, PASSIVE );
this.scrollbarRef.nativeElement.addEventListener( "mousedown", this.passiveStateHandleScrollbarMousedown );
}
);
}
// I teardown the passive-state, removing all state-specific event-handlers.
private passiveStateTeardown() : void {
this.viewportRef.nativeElement.removeEventListener( "scroll", this.passiveStateHandleViewportScroll, PASSIVE );
this.scrollbarRef.nativeElement.removeEventListener( "mousedown", this.passiveStateHandleScrollbarMousedown );
}
// I update the offset of the simulated scrollbar thumb to match the offset of the
// content within the viewport element.
private updateThumbPositionToMatchScrollPercentage() : void {
// Get short-hand references to the native DOM elements.
var scrollbarThumbElement = this.scrollbarThumbRef.nativeElement;
var offset = ( ( this.scrollbarHeight - this.scrollbarThumbHeight ) * this.scrollPercentage );
scrollbarThumbElement.style.transform = `translateY( ${ offset }px )`;
}
}
As you can see, there's quite a bit of logic in here; and, that's just to re-implement a small subset of the browser's native scrolling behavior. One can easily imagine the monumental amount of code it would take to fully implement a scrolling container. That's what's so exciting about this approach - it implements only what it needs to and leans on the native browser behavior for the most complicated parts!
Anyway, this was a really fun exploration of Slack's virtual scrollbar in Angular 9.1.12. This took me about 3-mornings to get working in a way that made me feel good about it; but, I think that was time well spent.
Want to use code from this post? Check out the license.
Reader Comments
Ben. This is very interesting.
I have used a library called perfect scrollbars for several years. It tries to emulate the event handling side of scrolling, which makes it a little janky:
https://github.com/mdbootstrap/perfect-scrollbar
What's interesting, is that the author of this library, never saw the simplicity of your approach.
I am fairly sure this solution didn't require any new language features, although I could be wrong, as I haven't really analysed the issue in any real depth.
Sometimes if you look at a problem for long enough, answers reveal themselves, organically.
This begs the question, how many other libraries could be fundamentally improved by applying a completely different approach to the same problem.
Thinking outside the box can be extremely rewarding.
Great exploration.
@Charles,
I think the real moral of this story is that if you see something that looks slightly out-of-the-ordinary, it pays to pop-open those Chrome dev-tools and take a look at what is going on. The only reason I even thought to look at Slack's implementation was because the scrollbar was overlapping with the content (in a transparent kind of way). And, I was like, "Hmmmmmmm" :D
Hi Ben,
Very interesting write up. it's very clever of slack, I did not think it was possible without implementing custom scroll bars from scratch.
@Hassam,
Yeah, I really like this for that reason - we get so much right out of the box with this approach. We only have to "patch" the dragging of the "thumb" (which most people don't do anyway since most people are using scroll-wheels and magic-mise and things of that nature). So, with not too much effort, you get a custom look-and-feel, but still get to leverage all the power of the browser's native capabilities.
I absolutely hate hate hate disappearing scrollbars. They are a UX abomination. At the very least, if you are going to do this, you need to respect the users wish to always display scrollbar (On Windows, in accessibility settings). Both Teams and Slack abuse this.
@Milind,
I actually tend to agree with you on this. I'm on a MacOS and I always turn on the "always show scrollbars" setting. I find it so strange to ever have an overflow container that isn't obviously scrollable.
What's odd about Slack is that desktop app always shows the scrollbars where as the web app is the one that conditionally hides it. I wonder if that has to do with how the two apps are honoring the user preferences. Makes me wonder if there is a CSS media query for the scrollbar preferences.
And, just as a note, what I loved about how Slack was hiding the scrollbar was more about the technique that they were using, as opposed to the fact that the scrollbar was being hidden. All so say, I'm generally on the same page.
Ben et al...
I think it is all about giving the impression that there is as much screen real estate as possible, on the mobile. But, I don't think creating illusions should override UX. So, I must agree with both of you.
But, I think we need to appreciate that this is more about how to emulate a great technique, rather than the pros and cons, on whether scroll bars should be visible, all the time, on the mobile.
@Charles,
Well said, good sir.