I have created a repository to solve this exact issue. Below, I have boiled the solution down to its essentials, but the repository with horizontal scrolling support and other customization options can be found here: https://github.com/alexspirgel/no-scroll
Here is a link straight to the working demo page: http://alexanderspirgel.com/no-scroll/demo/
My solution works by comparing the difference in dimensions between an outer element with a scrollbar and its inner element. Once the scrollbar size is calculated, the scrolling element can be set to overflow: hidden
and an offset equal to the scrollbar width can be applied to prevent content shift. Additionally, this solution places a pseudo element with a disabled scrollbar in the new space created after disabling scrolling.
First, setup the html so there is an inner element wrapped by an outer element (the outer and inner elements can be the <html>
and <body>
elements).
<html>
<body>
content here...
</body>
</html>
The script sets CSS variables and applies the correct HTML data attributes, but the rest is controlled by the CSS. Include this CSS so the elements have the necessary styles.
[data-no-scroll-element=outer] {
--no-scroll--y-scrollbar-width: 0px;
box-sizing: border-box;
position: relative;
padding: 0; /* Padding on outer elements breaks disabled scrollbar placeholder spacing. */
overflow: auto; /* Accounts for space of internal margins. */
}
[data-no-scroll-element=outer][data-no-scroll-y-state=no-scroll] {
overflow-y: hidden;
}
[data-no-scroll-element=outer]::after {
content: "";
box-sizing: border-box;
display: none;
position: sticky;
top: 0;
bottom: 0;
left: 0;
right: 0;
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: hidden;
pointer-events: none;
}
[data-no-scroll-element=outer][data-no-scroll-y-state=no-scroll]::after {
display: block;
overflow-y: scroll;
}
html[data-no-scroll-element=outer]::after {
height: 100vh; /* If the outer element is <html> the after element must be set to the viewport height to work properly. */
}
[data-no-scroll-element=inner] {
box-sizing: border-box;
display: block;
width: auto;
height: auto;
max-width: none;
max-height: none;
border: 0; /* Border on inner elements breaks scrollbar size calculations. */
margin: 0; /* Margin on inner elements breaks scrollbar size calculations. */
padding: 0; /* Padding on inner elements breaks scrollbar size calculations. */
overflow: auto; /* Accounts for space of internal margins. */
}
[data-no-scroll-element=outer][data-no-scroll-y-state=no-scroll] > [data-no-scroll-element=inner] {
margin-right: var(--no-scroll--y-scrollbar-width, 0px);
}
Include this JavaScript to handle the calculation of the scrollbar width and handle the switching between enabling and disabling scrolling.
class noScroll {
static isOuterElementDocumentElement(outerElement) {
if (outerElement === document.documentElement) {
return true;
}
else {
return false;
}
}
static getElementYScrollbarWidth(outerElement, innerElement) {
let size = 0;
let outerSize = outerElement.offsetWidth;
if (this.isOuterElementDocumentElement(outerElement)) {
outerSize = window.innerWidth;
}
const innerSize = innerElement.offsetWidth;
const outerElementComputedStyles = window.getComputedStyle(outerElement);
const borderLeftWidth = parseInt(outerElementComputedStyles.borderLeftWidth);
const borderRightWidth = parseInt(outerElementComputedStyles.borderRightWidth);
size = (outerSize - borderLeftWidth - borderRightWidth) - innerSize;
if (size < 0) {
size = 0;
}
return size;
}
static setYScrollbarWidthCSSVariable(outerElement, innerElement, value) {
if (typeof value === 'undefined') {
value = this.getElementYScrollbarWidth(outerElement, innerElement);
}
if (typeof value === 'number') {
value = value.toString() + 'px';
}
outerElement.style.setProperty('--no-scroll--y-scrollbar-width', value);
if (this.isOuterElementDocumentElement(outerElement)) {
outerElement.style.setProperty('--no-scroll--document-width-offset', value);
}
}
static isScrollEnabled(outerElement) {
if (outerElement.getAttribute('data-no-scroll-y-state') === 'no-scroll') {
return false;
}
return true;
}
static disableScroll(outerElement, innerElement) {
outerElement.setAttribute('data-no-scroll-element', 'outer');
innerElement.setAttribute('data-no-scroll-element', 'inner');
this.setYScrollbarWidthCSSVariable(outerElement, innerElement);
outerElement.setAttribute('data-no-scroll-y-state', 'no-scroll');
}
static enableScroll(outerElement, innerElement) {
this.setYScrollbarWidthCSSVariable(outerElement, innerElement, 0);
outerElement.setAttribute('data-no-scroll-y-state', 'scroll');
}
static toggleScroll(outerElement, innerElement) {
const isScrollEnabled = this.isScrollEnabled(outerElement);
if (isScrollEnabled) {
this.disableScroll(outerElement, innerElement);
}
else {
this.enableScroll(outerElement, innerElement);
}
}
};
Then the scroll can be disabled/enabled as you see fit by calling the methods like this:
const outerElement = document.querySelector('html');
const innerElement = document.querySelector('body');
// disable
noScroll.disableScroll(outerElement, innerElement);
// enable
noScroll.enableScroll(outerElement, innerElement);
// toggle
noScroll.toggleScroll(outerElement, innerElement);
If you are disabling scroll on the <html>
element, the width of the website will change. This will cause position: fixed
elements to have the undesirable "jump" in position. To solve this, when disabling scroll of the <html>
element, a CSS variable called --no-scroll--document-width-offset
is created with a value equal to the change in width. You can include this CSS variable in the position of fixed elements to prevent the "jump" like so:
.fixed-centered {
position: fixed;
left: calc((100% - var(--no-scroll--document-width-offset, 0px)) / 2);
transform: translateX(-50%);
}
I hope this works for you. Again this is a boiled down version of my more complete solution located here: https://github.com/alexspirgel/no-scroll