Skip to main content
Ben Nadel at cf.Objective() 2009 (Minneapolis, MN) with: Oguz Demirkapi
Ben Nadel at cf.Objective() 2009 (Minneapolis, MN) with: Oguz Demirkapi

Prevent Body Scrolling With A WindowScrolling Service When Showing A Modal Window In Angular 5.0.2

By
Published in Comments (10)

The other day, I came across an interesting post on David Walsh's blog about preventing body scrolling. In his article, he was using the CSS property, "overflow," to disable and re-enable scrollbars on the Body element. I had never seen this done before and it got me thinking about how I might implement this approach in an Angular 5 application. This is an especially interesting problem considering that the Body element is outside of the Angular 5 application boundary. To help keep my Angular components decoupled from the parent page, I've encapsulated the scrolling functionality inside a WindowScrolling service that can, itself, be consumed within the Angular component tree.

Run this demo in my JavaScript Demos project on GitHub.

A few weeks ago, I created an Angular attribute directly called [trapScroll] that would trap mouse wheel events and prevent scrolling in a parent element. This directive is intended for use in an "overflow: scroll" context; but, such an approach could also be used to prevent scrolling on the body element. However, an attribute directive isn't always so easy to apply. For example, in a routable view, there is no component element on which to bind the [trapScroll] directive.

As such, there are cases when it makes sense to provide an Angular service that specifically deals with the window / body scrolling functionality. For example, if I route to a Modal window, it would be nice to disable and then re-enable body scrolling when the modal window is opened and closed, respectively. This way, as the user is interacting with the modal window, they are not accidentally causing a scroll in the hidden (or mostly hidden) Body element.

To experiment with this idea, I created a WindowScrolling service that exposes two public methods:

  • enable()
  • disable()

The .disable() method prevents scrolling on the Body element by injecting a Style tag that assigns "overflow:hidden" to the Body. The .enable() method then re-enables scrolling on the Body by removing the aforementioned Style tag:

export class WindowScrolling {

	private styleTag: HTMLStyleElement;

	// I initialize the window-scrolling service.
	// --
	// CAUTION: This service makes direct references to the global DOCUMENT object.
	// Theoretically, the Renderer2 service should be able to provide an API that would
	// allow me to side-step direct DOM-references. However, the Renderer2 service cannot
	// be injected directly into another Service - only into a Directive. As such, I'm
	// just dropping all the pretenses and I'm using the document directly.
	constructor() {

		// Rather than directly overwriting the style of the BODY tag (which is dicey),
		// we're going to inject a STYLE element that overrides the scroll behavior. This
		// way we can add and remove the style in order to toggle the behavior.
		this.styleTag = this.buildStyleElement();

	}

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

	// I disable the scrolling feature on the main viewport.
	public disable() : void {

		document.body.appendChild( this.styleTag );

	}


	// I re-enable the scrolling feature on the main viewport.
	public enable() : void {

		document.body.removeChild( this.styleTag );

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I build and return a Style element that will prevent scrolling on the body.
	private buildStyleElement() : HTMLStyleElement {

		var style = document.createElement( "style" );

		style.type = "text/css";
		style.setAttribute( "data-debug", "Injected by WindowScrolling service." );
		style.textContent = `
			body {
				overflow: hidden !important ;
			}
		`;

		return( style );

	}

}

In this service, I'm making direct calls to the global document object. Generally speaking, this is frowned upon in the Angular world because it couples the application to the Browser environment (as opposed to the Server environment). I tried to use the Renderer2 service to maintain some degree of indirection; but, it seems that the Renderer2 service cannot be injected into a "Service" - it can only be injected into a Directive. Because of this, combined with the fact that I am completely befuddled by the idea of running an Angular app in any other context than the Browser, I decided to just reference the document directly and let my WindowService be the point of abstraction.

I can then inject this service into any one of my Angular components. In this demo, I've created a super simple modal window in my root component. When the root component opens the modal window, it's going to disable Body scrolling. And, when the root component closes the modal window, it's going to re-enable Body scrolling:

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

// Import the application components and services.
import { WindowScrolling } from "./window-scrolling";

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

@Component({
	selector: "my-app",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<p>
			<a (click)="toggleModal()" class="open">Open Modal Window</a>
		</p>

		<div *ngIf="isShowingModal" class="modal">
			<div class="modal__content">

				This is a modal &mdash; <a (click)="toggleModal()">Close the modal</a>

			</div>
		</div>

		<p *ngFor="let i of offsets">
			This is paragraph 1-{{ i }}.
		</p>

		<p>
			<a (click)="toggleModal()" class="open">Open Modal Window</a>
		</p>

		<p *ngFor="let i of offsets">
			This is paragraph 2-{{ i }}.
		</p>

		<p>
			<a (click)="toggleModal()" class="open">Open Modal Window</a>
		</p>
	`
})
export class AppComponent {

	public isShowingModal: boolean;
	public offsets: number[];

	private windowScrolling: WindowScrolling;

	// I initialize the app component.
	constructor( windowScrolling: WindowScrolling ) {

		this.windowScrolling = windowScrolling;
		this.offsets = this.range( 1, 20 );

	}

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

	// I open or close the modal window.
	public toggleModal() : void {

		// When we open the modal window, we want to prevent scrolling on the main
		// document. This way, if the user can scroll within the modal window, the
		// scroll will never bleed into the body context.
		if ( this.isShowingModal = ! this.isShowingModal ) {

			this.windowScrolling.disable();

		} else {

			this.windowScrolling.enable();

		}

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I build an increasing range for the given numbers, inclusive.
	private range( from: number, to: number ) : number[] {

		var values = [];

		for ( var i = from ; i <= to ; i++ ) {

			values.push( i );

		}

		return( values );

	}

}

As you can see, when I toggle the modal window, I'm either calling windowScrolling.enable() or windowScrolling.disable() to make sure that modal-window interactions don't bleed out into the body context. And, when we run this code and open the modal window, we can see that scrolling has been disabled:

Using a WindowScrolling service to disable scrolling on the body element when a modal window is open in Angular 5.0.2.

As you can see, when the modal window is opened, the WindowScrolling service is appending a Style tag to the Body element that forcibly hides the overflow of the body. This causes the scrollbars on the Body element to disappear (while maintaining the appropriate scrollTop), thereby preventing wheel events in the modal window from affecting the body offset.

And, when the modal window is closed, the WindowScrolling service will remove the Style tag, effectively re-enabling scrolling on the Body element:

Using a WindowScrolling service to disable scrolling on the body element when a modal window is open in Angular 5.0.2.

Apparently, this "overflow: hidden" trick has been around for a while; but, it's the first time that I've seen it. Ironically, after reading Walsh's blog, I found out that we actually use something similar in our production app. So, it seems like I'm fairly behind the curve on this matter. That said, I love the fact that we can easily prevent Body scrolling with a simple style change. Apparently this doesn't work as well on mobile devices; but, I don't think that such platform differences detract from this approach in the least.

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

Reader Comments

15,848 Comments

@Sergey,

Very true. That said, I consider this feature a "nice to have", not necessarily something that is absolutely required. So, if it doesn't work on a mobile device, I'm not overly concerned. I am not even sure that a modal window, in general, is a great UI to have on a mobile version of an app. Of course, I don't really have all that much mobile-dev experience, so please take that with a grain of salt.

15,848 Comments

@Manish,

The "Can't resolve all parameters" error usually means you are not including something as a Provider somewhere. So, Angular is going to try to pull your constructor arguments out of the Dependency-Injector; but, it can't find anything that matches your token.

Looking back at my code, I don't seem to be defining any meta-data for the WindowScrolling service. In newer versions of Angular, you would do something like:

@Injectable({
	providedIn: "root"
})
export class WindowScrolling {
	// ....
}

This would replace the providers item in the AppModule. It automatically provides the WindowScrolling service as a provider. But, even if you don't use the providedIn approach, I still think you need the @Injectable() meta-data on your Service. Honestly, I am not sure how my demo even compiled :D

2 Comments

Thanks for the article Ben.
In fact you can inject Renderer into a service using RenderFactory.
This is the same service, using renderer2:

import { Injectable, Renderer2, RendererFactory2, RendererStyleFlags2 } from '@angular/core';

const importantFlag = RendererStyleFlags2.Important

@Injectable({
    providedIn: 'root'
})
export class WindowScrollingService {

    private renderer: Renderer2

    constructor( private rendererFactory: RendererFactory2 ) {
        this.renderer = rendererFactory.createRenderer(null, null)
    }

    /* PUBLIC METHODS */

    /* disable scrolling on body */
    public disable() : void {        
        this.renderer.setStyle(document.body,'overflow', 'hidden', importantFlag);
    }

    /* enable scrolling on body */
    public enable() : void {
        this.renderer.removeStyle(document.body, 'overflow');
    }
}
15,848 Comments

@Asaf,

Interesting. I have not seen the RendererFactory before. Do you know if this is a "suggested approach"? Or, is this more of a hack that happens to work? Meaning, if there's not a more intuitive way to get the current Renderer, is it possibly an indication that a Service shouldn't be coupled to the Renderer?

At the end of the day, you're gonna be coupled to something; so, you either couple directly to the window, document, and body' or, you couple to the RenderFactory. So, perhaps its more or less the same thing.

2 Comments

@Ben,

I think you're right about coupling. Something's gotta give in a real-world app.

Regarding using that approach, I believe it's somewhere in the middle: it's not recommended by best practice, but Angular seems to be using it internally in their APIs, as can be seen here:
https://github.com/angular/angular/blob/e3140ae888ac4037a5f119efaec7b1eaf8726286/packages/core/src/render/api.ts#L129

I also read this suggested by several on StackOverflow:
https://stackoverflow.com/questions/42818299/access-the-renderer-from-a-service
https://stackoverflow.com/questions/44989666/service-no-provider-for-renderer2

15,848 Comments

@Asaf,

Thanks for the links, I will take a look. One of the most helpful things about the Angular docs is that every doc page links directly to its source in GitHub. It's awesome for seeing how things are working under the hood. That said, sometimes the "under the hood" stuff is so complicated, I can't even make heads-or-tails of it :D

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