Buggy snap-scrolling while DOM changes - javascript

I have a feed of photos that are scrolled vertically in my app and I'm using snap scroll.
The feed is a virtual scroller, i.e. - only X elements are actually on the DOM, and elements above and below are removed/inserted as needed.
Important to note that this is a mobile web app.
However, when I scroll and DOM changes happen while scrolling, I'm getting the following buggy behaviors:
The previous photo flickers while transitioning to the next photo
The feed scrolls to the previously seen photo (doesn't repro 100% of the times)
I created a simulation in Codepen to repro the issues; a div is added to the DOM every 0.5secs, and trying to scroll while on mobile view repros both issues - [link to Codepen], please use a mobile view.
HTML:
<div class="snap-scroll-container"></div>
JS:
const snapScrollContainer = document.querySelector(".snap-scroll-container");
[
"https://media.istockphoto.com/photos/gray-british-cat-kitten-picture-id1086004080?k=20&m=1086004080&s=612x612&w=0&h=tvQKNjBGIsfCmUPR8YVJYfjLrTZ9JINbisKRjMj87IY=",
"https://images.unsplash.com/photo-1595433707802-6b2626ef1c91?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=880&q=80",
"https://images.unsplash.com/photo-1592194996308-7b43878e84a6?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=687&q=80",
"https://images.unsplash.com/photo-1574144611937-0df059b5ef3e?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=764&q=80",
"https://www.warrenphotographic.co.uk/photography/bigs/08483-Ginger-kitten-portrait.jpg",
"https://img.freepik.com/premium-photo/kitten-portrait-beautiful-fluffy-gray-kitten-cat-animal-baby-british-blue-kitten-with-big-eyes-sits-beige-plaid-looking-camera-blue-background_221542-1665.jpg?w=740",
"https://libreshot.com/wp-content/uploads/2018/02/cute-kitten-portrait-861x1292.jpg",
"https://www.warrenphotographic.co.uk/photography/bigs/35147-Portrait-of-tabby-kitten-8-weeks-old.jpg",
].forEach((src) => {
const image = document.createElement("img");
const div = document.createElement("div");
div.setAttribute("class", "snap-scroll-child");
image.setAttribute("src", src);
div.appendChild(image);
snapScrollContainer.appendChild(div);
});
const addEmptyDiv = () => {
const div = document.createElement("div");
div.innerHTML = "bla";
snapScrollContainer.appendChild(div);
}
setInterval(addEmptyDiv, 500);
CSS:
body {
margin: 0;
padding: 0;
}
.snap-scroll-container {
scroll-snap-type: y mandatory;
height: 100vh;
overflow-y: scroll;
}
.snap-scroll-child {
scroll-snap-stop: always;
scroll-snap-align: end;
}
img {
object-fit: cover;
height: 100vh;
width: 100vw;
}
Unfortunately giving up on the virtual scroller is not an option as the perf hit will be large.
Going over SO and docs (including docs about restoring scroll position automatically after DOM changes), I couldn't find any plausible solution, other than maybe:
Implementing snap scroll in JS (I would rather not)
Deferring DOM changes until scrolling is complete (not optimal)
[Link to GIF showing the issue]

Related

Trigger IntersectionObserver when element is already in viewport

The IntersectionObserver is triggered when an element is visible in the viewport for a certain amount (0-100%). This means, when the element is already 100% in the viewport it does not trigger anymore, as there is no change on the threshold.
I have a element that has a height of 200vh and I want the IntersectionObserver to trigger, when I scroll over this element. So the element is always 100% inside the viewport.
Is there a way to trigger the observer while scrolling over the element?
I cannot use the scroll event, as I am using a CSS scroll-snap, which causes the event to be swallowed by the browser, before JS can detect it.
Hopefully I was able to grasp your challenge here, so I'll attempt to propose a solution that should work for your use case, even though there's no code to use as a reference.
From my understanding you're using scroll-snap to snap sections as the user interacts by doing scroll and your intention is to have the Intersection Observer to trigger as the users move from section to section.
In the following example you'll see how sections are being snapped but the debugger shows which section is being shown to the user.
const debuggerSpan = document.querySelector('#current-section');
const sections = [...document.querySelectorAll('.scroll-snap-item')];
const div = document.querySelector('.scroll-snap-container');
/*
* This method will get called any time a section touches the top
* of the viewport.
*/
const intersectionDetected = (entries, observer) => {
entries.forEach((entry) => {
const {
innerText
} = entry.target;
if (!entry.isIntersecting) return;
// Making it obvious that the current section is correct.
debuggerSpan.innerText = innerText;
});
};
const observer = new IntersectionObserver(intersectionDetected, {
/*
* Root should be div and not the default (doc).
*/
root: div,
/*
* Negative margin from the bottom creates an invisible line
* to detect intersections.
*
* The reason why the recommendation is to use -1% and -99% is to
* avoid the race condition between two intersections happening
* (AKA the section about to be out of the viewport and the section
* about to enter the viewport).
*/
rootMargin: '-1% 0% -99% 0%',
/*
* Default value but making it explicit as this is the
* only configuration that works.
*/
threshold: 0
});
sections.forEach(section => observer.observe(section));
.scroll-snap-item {
height: 100vh;
display: grid;
place-items: center;
font-size: 4rem;
scroll-snap-align: start;
}
.scroll-snap-container {
scroll-snap-type: y mandatory;
overflow-y: scroll;
height: 100vh;
}
/* Decorative stuff below*/
.scroll-snap-item:nth-child(odd) {
background-color: gray;
}
aside {
position: fixed;
font-size: 1.6rem;
bottom: 16px;
right: 16px;
background-color: #333;
color: white;
padding: 16px;
border-radius: 32px;
}
<div class="scroll-snap-container">
<section class="scroll-snap-item">1</section>
<section class="scroll-snap-item">2</section>
<section class="scroll-snap-item">3</section>
<section class="scroll-snap-item">4</section>
<section class="scroll-snap-item">5</section>
<section class="scroll-snap-item">6</section>
</div>
<aside>Current section: <span id="current-section"></span></aside>
I wrote a couple of practical posts that cover what's behind all this and what was the thought process to address this situation. Please feel free to give it a read and leave a comment if things are not clear enough:
Scrollspying made easy with the Intersection Observer API.
A graphical introduction to the Intersection Observer API.
Both are quick reads and should provide everything you need to tackle this and even more complex problems with the Intersection Observer. Also, feel free to play around with this tool I wrote called The Intersection Observer Playground where you can try out different configurations and see how they affect the intersection triggers.
Hope this is helpful!

Possible to trigger on Vimeo video click?

Is it possible to trigger an action on click if a video is set to background=1 (no controls)?
This is a Vimeo video, plus account (where background=1 is permitted).
Essentially, I have a Vimeo video with no controls set to loop and autoplay with a volume of 0. My implementation has an icon overlayed on top of the video, in the center. When clicked, it is set to full volume and the icon is hidden.
Once the volume is set to 1 and the icon is hidden, the person viewing should have the option of clicking the video so as to mute it (set volume to 0).
The problem is that I am not able to figure out how to target this click. I have tried attaching an .on('click') to the iframe, its parent, and as far up the chain as I can go but beyond that first click of the icon, the click is never registered.
Can anyone please offer any pointers on how to target a click on a Vimeo iframe video (or its parent container, etc)?
Here is my code thus far:
var iframe = document.getElementById('vimeo-video');
var player = new Vimeo.Player(iframe);
player.ready().then(function() {
var volume = 0
player.setVolume(volume);
$('#vimeo-video-play').on('click', function(event) {
if (volume > 0) {
player.setVolume(0);
} else {
player.setVolume(1);
}
$('#vimeo-video-play').hide();
});
});
Vimeo Promises
It appears that if you use Vimeo API, you must return a Promise. If you just want to do a simple task such as controlling the volume, the documentation gives this example:
player.setVolume(0.5).then(function(volume) {
// volume was set
}).catch(function(error) {
switch (error.name) {
case 'RangeError':
// the volume was less than 0 or greater than 1
break;
default:
// some other error occurred
break;
}
});
Not simple, and it's overkill. It's not apparent by looking at it but if you see then(), await async, new Promise you can say with 99.9% certainty that a Promise will be returned. I haven't looked deep enough into player.js but I think each method is wrapped in a Promise so as far as I could tell, we can just return the method without using all of that extra crap. So compare the above code to the following:
var sVol = player.setVolume(1); return sVol;
So I believe when invoking a Vimeo API method, we can return the function as a value. There's no work involving what exactly that value is because it's going to be either resolved or rejected. A Promise is also immutable so returning the function itself should be a guaranteed resolve (concerning Vimeo methods, not Promises in general).
Overlay Layout
Instead of clicking an iframe which is filled with a video player that will do 100 other tasks except your custom callback, you need to click an element outside of the iframe. As a background video without controls, you are very limited. I suggest an element that covers the iframe player edge to edge so that the user clicks it and nothing else. The following are the steps to setup an overlay:
Wrap the iframe player (#vFrame0 in the demo) in a relpos🞳 parent container (a.k.a. .box)
Place an older sibling🗡 abspos🞳 element (a.k.a. .overlay) inside the parent with the iframe player.
Set the older sibling "above" the player by setting its z-index at l more than the iframe player and the necessary CSS properties (see demo CSS for .overlay) to ensure that the older sibling covers the iframe player edge to edge completely.
Register the click event to the overlay element so when the player ignores the click event, the event will bubble back to the older sibling overlay element. The overlay element is now a proxy of sorts and will run the callback:
var sVol = player.setVolume(1); return sVol
Demo
This Demo does not function because there are conflicts between Vimeo's connections and SO's security measures. For a functioning Demo review this Plunker or this Plunker.
<!DOCTYPE html>
<html>
<head>
<base href="https://player.vimeo.com/api/demo">
<meta charset='utf-8'>
<style>
.box {
display: table;
border: 3px dashed red;
position: relative;
}
.overlay {
cursor: pointer;
display: table-cell;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
min-height: 100%;
z-index: 1;
}
.overlay::before {
position: absolute;
cursor: pointer;
display: block;
content: '🕪';
font-size: 2em;
width: 2em;
height: 2em;
color: cyan;
opacity: 0;
transition: opacity .5s ease 3s;
}
.overlay:hover::before {
opacity: 1;
transition: .5s ease;
}
.mute.overlay::before {
content: '🕩';
}
</style>
</head>
<body>
<figure class='box'>
<figcaption class='overlay mute'></figcaption>
<div id='vFrame0' data-vimeo-id="76979871" data-vimeo-autoplay data-vimeo-background></div>
</figure>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src='https://player.vimeo.com/api/player.js'></script>
<script>
var player = new Vimeo.Player('vFrame0', options);
var options = {
mute: true
};
$('.overlay').on('click', function(e) {
var state = $(this).hasClass('mute') ? player.setVolume(1) : player.setVolume(0);
$(this).toggleClass('mute');
return state;
});
</script>
</body>
</html>
🞳relpos: position: relative | abspos position: absolute
🗡older sibling an element that is positioned before the element being referred to.
$('.button-trigger').click(function (e) {
let element = $('iframe').attr('src');
element = element.replace("autoplay=0", "autoplay=1");
$('iframe').attr('src', element);
});

Convert vh units to px in JS

Unfortunately 100vh is not always the same as 100% browser height as can be shown in the following example.
html,
body {
height: 100%;
}
body {
overflow: scroll;
}
.vh {
background-color: blue;
float: left;
height: 50vh;
width: 100px;
}
.pc {
background-color: green;
float: left;
height: 50%;
width: 100px;
}
<div class="vh"></div>
<div class="pc"></div>
The issue is more pronounced on iPhone 6+ with how the upper location bar and lower navigation bar expand and contract on scroll, but are not included in the calculation for 100vh.
The actual value of 100% height can be acquired by using window.innerHeight in JS.
Is there a convenient way to calculate the current conversion of 100vh to pixels in JS?
I'm trying to avoid needing to generate dummy elements with inline styles just to calculate 100vh.
For purposes of this question, assume a hostile environment where max-width or max-height may be producing incorrect values, and there isn't an existing element with 100vh anywhere on the page. Basically, assume that anything that can go wrong has with the exception of native browser functions, which are guaranteed to be clean.
The best I've come up with so far is:
function vh() {
var div,
h;
div = document.createElement('div');
div.style.height = '100vh';
div.style.maxHeight = 'none';
div.style.boxSizing = 'content-box';
document.body.appendChild(div);
h = div.clientHeight;
document.body.removeChild(div);
return h;
}
but it seems far too verbose for calculating the current value for 100vh, and I'm not sure if there are other issues with it.
How about:
function viewportToPixels(value) {
var parts = value.match(/([0-9\.]+)(vh|vw)/)
var q = Number(parts[1])
var side = window[['innerHeight', 'innerWidth'][['vh', 'vw'].indexOf(parts[2])]]
return side * (q/100)
}
Usage:
viewportToPixels('100vh') // window.innerHeight
viewportToPixels('50vw') // window.innerWidth / 2
The difference comes from the scrollbar scrollbar.
You'll need to add the height of the scrollbar to the window.innerHeight. There doesn't seem to be a super solid way of doing this, per this other question:
Getting scroll bar width using JavaScript

Programmatically Resizing Divs

I'm working on an HTML5 browser game that can be divided into 3 parts: two UI panels on the left and right of a center set of square canvases for the playing surface. The three panels need to be horizontally aligned, and the total game needs to keep an aspect ratio of 16:9. The left and right panels should be of equal widths, and all three panels must be of equal height. I have specified a minimum width and height inside a resize() function called when an onresize event is detected.
Currently, each panel is a div, and all three are contained inside a section. Right now, the section isn't necessary, but I want to keep the game separated from extra content at the bottom of the screen that I might choose to add later.
The CSS style is as follows:
* {
vertical-align: baseline;
font-weight: inherit;
font-family: inherit;
font-style: inherit;
font-size: 100%;
border: 0 none;
outline: 0;
padding: 0;
margin: 0;
}
#gameSection {
white-space: nowrap;
overflow-x: hide;
overflow-y: hide;
}
#leftPanel, #centerPanel, #rightPanel {
display: inline-block;
}
#leftPanel {
background-color: #6495ed;
}
#centerPanel {
background-color: #e0ffff;
}
#rightPanel {
background-color: #b0c4de;
Right now, I have set the background color of each div just to show me when I'm correctly setting the size of each div.
The body of my HTML document is as follows:
<body onresize="resize()">
<section id="gameSection">
<div id="leftPanel">Left Panel.</div>
<div id="centerPanel">Center Panel.</div>
<div id="rightPanel">Right Panel.</div>
</section>
</body>
And finally, my resize() function (I created a separate function for resizing the game in case I add more elements below later):
function resize() {
var MIN_GAME_WIDTH = 800;
var MIN_GAME_HEIGHT = 450;
var GAME_ASPECT_RATIO = 16 / 9;
var width = window.innerWidth;
var height = window.innerHeight;
var gWidth, gHeight;
if(width < MIN_GAME_WIDTH || height < MIN_GAME_HEIGHT) {
gWidth = MIN_GAME_WIDTH;
gHeight = MIN_GAME_HEIGHT;
}
else if ((width / height) > GAME_ASPECT_RATIO) {
<!-- width is too large for height -->
gHeight = height;
gWidth = height * GAME_ASPECT_RATIO;
}
else {
<!-- height is too large for width -->
gWidth = width;
gHeight = width / GAME_ASPECT_RATIO;
}
resizeGame(gWidth, gHeight, GAME_ASPECT_RATIO);
}
function resizeGame(var gWidth, var gHeight, var aspectRatio) {
var gSection = document.getElementById("gameSection");
var lPanel = document.getElementById("leftPanel");
var cPanel = document.getElementById("centerPanel");
var rPanel = document.getElementById("rightPanel");
gSection.height = gHeight;
gSection.width = gWidth;
<!-- should the below be taken care of in the CSS? -->
lPanel.height = gHeight;
cPanel.height = gHeight;
rPanel.height = gHeight;
cPanel.width = cPanel.height;
lPanel.width = (gWidth - cPanel.width) / 2;
rPanel.width = lPanel.width;
}
I've tried a number of different commands to resize the divs, but it just isn't working for me. When I try adding test canvases, color appears, but the boxes still aren't the correct size. I have also considered loading an invisible background image to each div and scaling it to the desired size; however, I was able to resize my canvas using the above method before and it seemed to work just fine.
Additional Notes
While I've already had pretty good success resizing a single canvas, I don't want to use just one canvas for the game because not all parts of the UI need to be drawn at the same time.
I'm trying to keep this solely in Javascript.
I suspect that I could just use CSS to handle resizing by fixing the aspect ratio to 16:9 and using width:56.25% for the center panel and width:21.875% for the side panels, but that limits me to one aspect ratio and doesn't explain why my above script isn't working.
I can provide the entire HTML file if needed. This is what it's supposed to look like:
End Goal (without right panel)
Thank you!
UDPATE:
jsfiddle
I got it kind of working here. I made a lot of changes/minor fixes to the code before finding what was wrong (other than various syntax errors):
You were using .width and .height instead of .style.width and .style.height, and you were applying integers to these instead of strings with "px" appended to them. Both of these things are completely understandable to miss.
I also moved the onresize from the body tag into the JS, don't know why it wasn't working on jsfiddle, but this is good practice anyways.
In the future: learn how to debug JS using the console and when you ask questions, use small examples, not your entire codebase. This question could have been simplified to "How do I resize a div?" with one line of JS and one div. You also should consider not doing this specific thing in JS, and using flexbox as redbmk said.

How to disable scroll without hiding it?

I'm trying to disable the html/body scrollbar of the parent while I'm using a lightbox. The main word here is disable. I do not want to hide it with overflow: hidden;.
The reason for this is that overflow: hidden makes the site jump and take up the area where the scroll was.
I want to know if its possible to disable a scrollbar while still showing it.
If the page under the overlayer can be "fixed" at the top, when you open the overlay you can set
body {
position: fixed;
overflow-y:scroll
}
you should still see the right scrollbar but the content is not scrollable. When you close the overlay just revert these properties with
body {
position: static;
overflow-y:auto
}
I just proposed this way only because you wouldn't need to change any scroll event
What if I already scrolled the page?
if you get the document.documentElement.scrollTop property via javascript just before the layer opening, you could dynamically assign that value as top property of the body element: with this approach the page will keep its current scroll position, no matter if you're on top or if you have already scrolled.
Css
.noscroll {
position: fixed;
inline-size: 100%;
overflow-y:scroll
}
JS
$('body').css('top', -(document.documentElement.scrollTop) + 'px')
.addClass('noscroll');
Four little additions to the accepted solution:
Apply 'noscroll' to html instead of to body to prevent double scroll bars in IE
To check if there's actually a scroll bar before adding the 'noscroll' class. Otherwise, the site will also jump pushed by the new non-scrolling scroll bar.
To keep any possible scrollTop so the entire page doesn't go back to the top (like Fabrizio's update, but you need to grab the value before adding the 'noscroll' class)
Not all browsers handle scrollTop the same way as documented at http://help.dottoro.com/ljnvjiow.php
Complete solution that seems to work for most browsers:
CSS
html.noscroll {
position: fixed;
overflow-y: scroll;
width: 100%;
}
Disable scroll
if ($(document).height() > $(window).height()) {
var scrollTop = ($('html').scrollTop()) ? $('html').scrollTop() : $('body').scrollTop(); // Works for Chrome, Firefox, IE...
$('html').addClass('noscroll').css('top',-scrollTop);
}
Enable scroll
var scrollTop = parseInt($('html').css('top'));
$('html').removeClass('noscroll');
$('html,body').scrollTop(-scrollTop);
Thanks to Fabrizio and Dejan for putting me on the right track and to Brodingo for the solution to the double scroll bar
With jQuery inluded:
disable
$.fn.disableScroll = function() {
window.oldScrollPos = $(window).scrollTop();
$(window).on('scroll.scrolldisabler',function ( event ) {
$(window).scrollTop( window.oldScrollPos );
event.preventDefault();
});
};
enable
$.fn.enableScroll = function() {
$(window).off('scroll.scrolldisabler');
};
usage
//disable
$("#selector").disableScroll();
//enable
$("#selector").enableScroll();
I'm the OP
With the help of answer from fcalderan I was able to form a solution. I leave my solution here as it brings clarity to how to use it, and adds a very crucial detail, width: 100%;
I add this class
body.noscroll
{
position: fixed;
overflow-y: scroll;
width: 100%;
}
this worked for me and I was using Fancyapp.
This worked really well for me....
// disable scrolling
$('body').bind('mousewheel touchmove', lockScroll);
// enable scrolling
$('body').unbind('mousewheel touchmove', lockScroll);
// lock window scrolling
function lockScroll(e) {
e.preventDefault();
}
just wrap those two lines of code with whatever decides when you are going to lock scrolling.
e.g.
$('button').on('click', function() {
$('body').bind('mousewheel touchmove', lockScroll);
});
You cannot disable the scroll event, but you can disable the related actions that lead to a scroll, like mousewheel and touchmove:
$('body').on('mousewheel touchmove', function(e) {
e.preventDefault();
});
You can hide the body's scrollbar with overflow: hidden and set a margin at the same time so that the content doesn't jump:
let marginRightPx = 0;
if(window.getComputedStyle) {
let bodyStyle = window.getComputedStyle(document.body);
if(bodyStyle) {
marginRightPx = parseInt(bodyStyle.marginRight, 10);
}
}
let scrollbarWidthPx = window.innerWidth - document.body.clientWidth;
Object.assign(document.body.style, {
overflow: 'hidden',
marginRight: `${marginRightPx + scrollbarWidthPx}px`
});
And then you can add a disabled scrollbar to the page to fill in the gap:
textarea {
overflow-y: scroll;
overflow-x: hidden;
width: 11px;
outline: none;
resize: none;
position: fixed;
top: 0;
right: 0;
bottom: 0;
border: 0;
}
<textarea></textarea>
I did exactly this for my own lightbox implementation. Seems to be working well so far.
Here is a working demo. This is how you can do this with pure JavaScript:
const { body, documentElement } = document;
let { scrollTop } = document.documentElement;
function disableScroll() {
scrollTop = documentElement.scrollTop;
body.style.top = `-${scrollTop}px`;
body.classList.add("scroll-disabled");
}
function enableScroll() {
body.classList.remove("scroll-disabled");
documentElement.scrollTop = scrollTop;
body.style.removeProperty("top");
}
And this is the CSS:
.scroll-disabled {
position: fixed;
width: 100%;
overflow-y: scroll;
}
We use position: fixed on body to prevent it from being scrollable and we use overflow-y to show the scrollbar. We also need to set width because of how position: fixed works.
We keep track of the scroll position and update it when disabling scroll so that we can position body appropriately using top when scroll is disabled and restore the scroll position when it is enabled. Otherwise body will keep jumping to the top when disabling or enabling scroll.
When enabling scroll we remove the top style from body. This prevents it from breaking your layout if you have a different position than static on body.
If you are using scroll-behavior: smooth on html, you also need to modify the enableScroll function like this:
function enableScroll() {
body.classList.remove("scroll-disabled");
// Set "scroll-behavior" to "auto"
documentElement.style.scrollBehavior = "auto";
documentElement.scrollTop = scrollTop;
// Remove "scroll-behavior: auto" after restoring scroll position
documentElement.style.removeProperty("scroll-behavior");
body.style.removeProperty("top");
}
We need to temporarily set scroll-behavior to auto so that there are no jumps.
This is the solution we went with. Simply save the scroll position when the overlay is opened, scroll back to the saved position any time the user attempted to scroll the page, and turn the listener off when the overlay is closed.
It's a bit jumpy on IE, but works like a charm on Firefox/Chrome.
var body = $("body"),
overlay = $("#overlay"),
overlayShown = false,
overlayScrollListener = null,
overlaySavedScrollTop = 0,
overlaySavedScrollLeft = 0;
function showOverlay() {
overlayShown = true;
// Show overlay
overlay.addClass("overlay-shown");
// Save scroll position
overlaySavedScrollTop = body.scrollTop();
overlaySavedScrollLeft = body.scrollLeft();
// Listen for scroll event
overlayScrollListener = body.scroll(function() {
// Scroll back to saved position
body.scrollTop(overlaySavedScrollTop);
body.scrollLeft(overlaySavedScrollLeft);
});
}
function hideOverlay() {
overlayShown = false;
// Hide overlay
overlay.removeClass("overlay-shown");
// Turn scroll listener off
if (overlayScrollListener) {
overlayScrollListener.off();
overlayScrollListener = null;
}
}
// Click toggles overlay
$(window).click(function() {
if (!overlayShown) {
showOverlay();
} else {
hideOverlay();
}
});
/* Required */
html, body { margin: 0; padding: 0; height: 100%; background: #fff; }
html { overflow: hidden; }
body { overflow-y: scroll; }
/* Just for looks */
.spacer { height: 300%; background: orange; background: linear-gradient(#ff0, #f0f); }
.overlay { position: fixed; top: 20px; bottom: 20px; left: 20px; right: 20px; z-index: -1; background: #fff; box-shadow: 0 0 5px rgba(0, 0, 0, .3); overflow: auto; }
.overlay .spacer { background: linear-gradient(#88f, #0ff); }
.overlay-shown { z-index: 1; }
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<h1>Top of page</h1>
<p>Click to toggle overlay. (This is only scrollable when overlay is <em>not</em> open.)</p>
<div class="spacer"></div>
<h1>Bottom of page</h1>
<div id="overlay" class="overlay">
<h1>Top of overlay</h1>
<p>Click to toggle overlay. (Containing page is no longer scrollable, but this is.)</p>
<div class="spacer"></div>
<h1>Bottom of overlay</h1>
</div>
I like to stick to the "overflow: hidden" method and just add padding-right that's equal to the scrollbar width.
Get scrollbar width function, by lostsource.
function getScrollbarWidth() {
var outer = document.createElement("div");
outer.style.visibility = "hidden";
outer.style.width = "100px";
outer.style.msOverflowStyle = "scrollbar"; // needed for WinJS apps
document.body.appendChild(outer);
var widthNoScroll = outer.offsetWidth;
// force scrollbars
outer.style.overflow = "scroll";
// add innerdiv
var inner = document.createElement("div");
inner.style.width = "100%";
outer.appendChild(inner);
var widthWithScroll = inner.offsetWidth;
// remove divs
outer.parentNode.removeChild(outer);
return widthNoScroll - widthWithScroll;
}
When showing the overlay, add "noscroll" class to html and add padding-right to body:
$(html).addClass("noscroll");
$(body).css("paddingRight", getScrollbarWidth() + "px");
When hiding, remove the class and padding:
$(html).removeClass("noscroll");
$(body).css("paddingRight", 0);
The noscroll style is just this:
.noscroll { overflow: hidden; }
Note that if you have any elements with position:fixed you need to add the padding to those elements too.
Another solution to get rid of content jump on fixed modal, when removing body scroll is to normalize page width:
body {width: 100vw; overflow-x: hidden;}
Then you can play with fixed position or overflow:hidden for body when the modal is open. But it will hide horizontal scrollbars - usually they're not needed on responsive website.
you can keep overflow:hidden but manage scroll position manually:
before showing keep trace of actual scroll position:
var scroll = [$(document).scrollTop(),$(document).scrollLeft()];
//show your lightbox and then reapply scroll position
$(document).scrollTop(scroll[0]).scrollLeft(scroll[1]);
it should work
<div id="lightbox"> is inside the <body> element, thus when you scroll the lightbox you also scroll the body. The solution is to not extend the <body> element over 100%, to place the long content inside another div element and to add a scrollbar if needed to this div element with overflow: auto.
html {
height: 100%
}
body {
margin: 0;
height: 100%
}
#content {
height: 100%;
overflow: auto;
}
#lightbox {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
<html>
<body>
<div id="content">much content</div>
<div id="lightbox">lightbox<div>
</body>
</html>
Now, scrolling over the lightbox (and the body as well) has no effect, because the body is no longer than 100% of the screen height.
I had a similar problem: a left-hand menu that, when it appears, prevents scrolling. As soon as height was set to 100vh, the scrollbar disappeared and the content jerked to the right.
So if you don't mind keeping the scrollbar enabled (but setting the window to full height so it won't actually scroll anywhere) then another possibility is setting a tiny bottom margin, which will keep the scroll bars showing:
body {
height: 100vh;
overflow: hidden;
margin: 0 0 1px;
}
All modal/lightbox javascript-based systems use an overflow when displaying the modal/lightbox, on html tag or body tag.
When lightbox is show, the js push a overflow hidden on html or body tag.
When lightbox is hidden, some remove the hidden other push a overflow auto on html or body tag.
Developers who work on Mac, do not see the problem of the scrollbar.
Just replace the hidden by an unset not to see the content slipping under the modal of the removal of the scrollbar.
Lightbox open/show:
<html style="overflow: unset;"></html>
Lightbox close/hide:
<html style="overflow: auto;"></html>
If the page under the overlayer can be "fixed" at the top, when you open the overlay you can set
.disableScroll { position: fixed; overflow-y:scroll }
provide this class to the scrollable body, you should still see the right scrollbar but the content is not scrollable.
To maintain the position of the page do this in jquery
$('body').css('top', - ($(window).scrollTop()) + 'px').addClass('disableScroll');
When you close the overlay just revert these properties with
var top = $('body').position().top;
$('body').removeClass('disableScroll').css('top', 0).scrollTop(Math.abs(top));
I just proposed this way only because you wouldn't need to change any scroll event
This will stop the viewport jumping to the top by saving the scroll position and restoring it on enabling scrolling.
CSS
.no-scroll{
position: fixed;
width:100%;
min-height:100vh;
top:0;
left:0;
overflow-y:scroll!important;
}
JS
var scrollTopPostion = 0;
function scroll_pause(){
scrollTopPostion = $(window).scrollTop();
$("body").addClass("no-scroll").css({"top":-1*scrollTopPostion+"px"});
}
function scroll_resume(){
$("body").removeClass("no-scroll").removeAttr("style");
$(window).scrollTop(scrollTopPostion);
}
Now all you need to do is to call the functions
$(document).on("click","#DISABLEelementID",function(){
scroll_pause();
});
$(document).on("click","#ENABLEelementID",function(){
scroll_resume();
});
The position: fixed; solution has a drawback - the page jumps to the top when this style is applied. Angular's Material Dialog has a nice solution, where they fake the scroll position by applying positioning to the html element.
Below is my revised algorithm for vertical scrolling only. Left scroll blocking is done in the exact same manner.
// This class applies the following styles:
// position: fixed;
// overflow-y: scroll;
// width: 100%;
const NO_SCROLL_CLASS = "bp-no-scroll";
const coerceCssPixelValue = value => {
if (value == null) {
return "";
}
return typeof value === "string" ? value : `${value}px`;
};
export const blockScroll = () => {
const html = document.documentElement;
const documentRect = html.getBoundingClientRect();
const { body } = document;
// Cache the current scroll position to be restored later.
const cachedScrollPosition =
-documentRect.top || body.scrollTop || window.scrollY || document.scrollTop || 0;
// Cache the current inline `top` value in case the user has set it.
const cachedHTMLTop = html.style.top || "";
// Using `html` instead of `body`, because `body` may have a user agent margin,
// whereas `html` is guaranteed not to have one.
html.style.top = coerceCssPixelValue(-cachedScrollPosition);
// Set the magic class.
html.classList.add(NO_SCROLL_CLASS);
// Return a function to remove the scroll block.
return () => {
const htmlStyle = html.style;
const bodyStyle = body.style;
// We will need to seamlessly restore the original scroll position using
// `window.scroll`. To do that we will change the scroll behavior to `auto`.
// Here we cache the current scroll behavior to restore it later.
const previousHtmlScrollBehavior = htmlStyle.scrollBehavior || "";
const previousBodyScrollBehavior = bodyStyle.scrollBehavior || "";
// Restore the original inline `top` value.
htmlStyle.top = cachedHTMLTop;
// Remove the magic class.
html.classList.remove(NO_SCROLL_CLASS);
// Disable user-defined smooth scrolling temporarily while we restore the scroll position.
htmlStyle.scrollBehavior = bodyStyle.scrollBehavior = "auto";
// Restore the original scroll position.
window.scroll({
top: cachedScrollPosition.top
});
// Restore the original scroll behavior.
htmlStyle.scrollBehavior = previousHtmlScrollBehavior;
bodyStyle.scrollBehavior = previousBodyScrollBehavior;
};
};
The logic is very simple and can be simplified even more if you don't care about certain edge cases. For example, this is what I use:
export const blockScroll = () => {
const html = document.documentElement;
const documentRect = html.getBoundingClientRect();
const { body } = document;
const screenHeight = window.innerHeight;
// Only do the magic if document is scrollable
if (documentRect.height > screenHeight) {
const cachedScrollPosition =
-documentRect.top || body.scrollTop || window.scrollY || document.scrollTop || 0;
html.style.top = coerceCssPixelValue(-cachedScrollPosition);
html.classList.add(NO_SCROLL_CLASS);
return () => {
html.classList.remove(NO_SCROLL_CLASS);
window.scroll({
top: cachedScrollPosition,
behavior: "auto"
});
};
}
};
I have made this one function, that solves this problem with JS.
This principle can be easily extended and customized that is a big pro for me.
Using this js DOM API function:
const handleWheelScroll = (element) => (event) => {
if (!element) {
throw Error("Element for scroll was not found");
}
const { deltaY } = event;
const { clientHeight, scrollTop, scrollHeight } = element;
if (deltaY < 0) {
if (-deltaY > scrollTop) {
element.scrollBy({
top: -scrollTop,
behavior: "smooth",
});
event.stopPropagation();
event.preventDefault();
}
return;
}
if (deltaY > scrollHeight - clientHeight - scrollTop) {
element.scrollBy({
top: scrollHeight - clientHeight - scrollTop,
behavior: "smooth",
});
event.stopPropagation();
event.preventDefault();
return;
}
};
In short, this function will stop event propagation and default behavior if the scroll would scroll something else then the given element (the one you want to scroll in).
Then you can hook and unhook this up like this:
const wheelEventHandler = handleWheelScroll(elementToScrollIn);
window.addEventListener("wheel", wheelEventHandler, {
passive: false,
});
window.removeEventListener("wheel", wheelEventHandler);
Watch out for that it is a higher order function so you have to keep a reference to the given instance.
I hook the addEventListener part in mouse enter and unhook the removeEventListener in mouse leave events in jQuery, but you can use it as you like.
I’ve noticed that the YouTube website does exactly this. So by inspecting it a bit I’ve been able to determine that they’re using #polymer/iron-overlay-behavior and fortunately, it can be used rather unobtrusively outside of web components/Polymer:
import {
pushScrollLock,
removeScrollLock,
} from '#polymer/iron-overlay-behavior/iron-scroll-manager';
// lock scroll everywhere except scrollElement
pushScrollLock(scrollElement);
// restore scrolling
removeScrollLock(scrollElement);
Allows scrolling in selected element
Doesn't mess with styling in any way
Is battle-tested on YouTube website
It seems like a mature solution and surely the best I was able to find. The package is a bit heavy but I guess much of it become unbundled, when importing just the iron-scroll-manager.
Cheers
I have some other fixed elements in the page and setting body's position to fixed caused a bunch of other problems, so I did it in a hacky way:
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
// on opening modal
document.body.style.overflow = "hidden"
document.body.style.paddingRight = `${scrollbarWidth}px`
// on closing modal
document.body.style.overflow = "unset",
document.body.style.paddingRight = "0px"
The idea is to add a padding-right with the same width as browser's scrollbar, to mimick a fake scrollbar and prevent the content shift.
Crude but working way will be to force the scroll back to top, thus effectively disabling scrolling:
var _stopScroll = false;
window.onload = function(event) {
document.onscroll = function(ev) {
if (_stopScroll) {
document.body.scrollTop = "0px";
}
}
};
When you open the lightbox raise the flag and when closing it,lower the flag.
Live test case.
React version:
👍🏻 keeps scroll position
👍🏻 no layout reflow
👍🏻 typescript
import type {
PropsWithChildren
} from "react";
import {
useCallback,
useState,
useContext,
createContext
} from "react";
type BlanketContextShape = {
isOpen ? : boolean;
zIndex: number;
color: string;
setIsOpen: (yesno ? : boolean) => void;
};
const BlanketContext = createContext<BlanketContextShape>({
zIndex: 500,
color: `rgba(0,0,0,0.5)`,
setIsOpen() {
return;
},
});
function useBlanket() {
const context = useContext(BlanketContext);
if (!context)
throw new Error(
"useBlanket can only be used within children of BlanketProvider"
);
return context;
}
function BlanketProvider({
children,
color,
zIndex,
}: PropsWithChildren<{
zIndex ? : number;
color ? : string;
}>) {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [top, setTop] = useState(0);
const handleSetIsOpen = useCallback(
(yesno?: boolean) => {
if (typeof window === "undefined") return;
if (yesno) {
const scrollTop = window.scrollY;
document.body.style.top = `-${scrollTop}px`;
setTop(scrollTop);
}
if (window.innerHeight < document.body.scrollHeight) {
document.body.style.overflowY = (!!yesno && "scroll") || "auto";
document.body.style.position = (!!yesno && "fixed") || "static";
}
window.scrollTo({ top });
setIsOpen(() => !!yesno);
}, [top]
);
return (
<BlanketContext.Provider
value={{
isOpen,
setIsOpen: handleSetIsOpen,
color: color || `rgba(0,0,0,0.5)`,
zIndex: zIndex || 200
}}>
{children}
</BlanketContext.Provider>
);
}
function Blanket({
children
}: PropsWithChildren) {
const {
isOpen,
setIsOpen,
zIndex,
color
} = useBlanket();
return (
<>
{isOpen && (
<div
style={{
position: "fixed",
backgroundColor: color,
top: 0,
height: "100vh",
width: "100vw",
zIndex: zIndex,
}}
onClick = {() => setIsOpen(false)}
/>
)}
{children}
</>
);
}
https://gist.github.com/airtonix/c8c9af146185646e7451faa0f2ac96b7
use it like:
// app
<BlanketProvider color='red'>
<YourView />
</BlanketProvider>
// YourView
...
const { isOpen, setIsOpen } = useBlanket();
return (
<>
<Blanket>
{isOpen && <SomeThingWithHigherZindex />}
</Blanket>
<Button onClick={() => setIsOpen(true)}>Do A Thing</Button>
</>
)
When setting isOpen to true, we track the current scroll from top and store it.
We only set it on the body styles if we're opening, because what we do next will cause window.scrollY to be 0.
Then we test if the document is taller than the viewport.
If it is then we set the Y overflow to scroll to ensure that the scrollbar doesn't vanish and that there's no layout reflow jump.
Set body to position fixed, preventing it from scrolling and ensure that the document is at the correct scrolling position in order to counteract position: fixed;
You can do it with Javascript:
// Classic JS
window.onscroll = function(ev) {
ev.preventDefault();
}
// jQuery
$(window).scroll(function(ev) {
ev.preventDefault();
}
And then disable it when your lightbox is closed.
But if your lightbox contains a scroll bar, you won't be able to scroll while it's open. This is because window contains both body and #lightbox.
So you have to use an architecture like the following one:
<body>
<div id="global"></div>
<div id="lightbox"></div>
</body>
And then apply the onscroll event only on #global.
I solved this problem with a scrollLock method that set up listeners for scroll wheel events and key down events, and a preventScroll method that handled the events. Something like this:
preventScroll = function (e) {
// prevent scrollwheel events
e.preventDefault();
e.stopPropagation();
// prevent keydown events
var keys = [32, 33, 34, 35, 37, 38, 39, 40];
if (keys.includes(e.keyCode)) {
e.preventDefault();
}
return false;
}
scrollLock = function (lock) {
if (lock) {
document.querySelector("#container").addEventListener("wheel", preventScroll);
document.addEventListener("keydown", preventScroll);
}
else {
document.querySelector("#container").removeEventListener("wheel", preventScroll);
document.querySelector("#container").removeEventListener("keydown", preventScroll);
}
}

Categories