Change style header/nav with Intersection Observer (IO) - javascript

Fiddle latest
I started this question with the scroll event approach, but due to the suggestion of using IntersectionObserver which seems much better approach i'm trying to get it to work in that way.
What is the goal:
I would like to change the style (color+background-color) of the header depending on what current div/section is observed by looking for (i'm thinking of?) its class or data that will override the default header style (black on white).
Header styling:
font-color:
Depending on the content (div/section) the default header should be able to change the font-color into only two possible colors:
black
white
background-color:
Depending on the content the background-color could have unlimited colors or be transparent, so would be better to address that separate, these are the probably the most used background-colors:
white (default)
black
no color (transparent)
CSS:
header {
position: fixed;
width: 100%;
top: 0;
line-height: 32px;
padding: 0 15px;
z-index: 5;
color: black; /* default */
background-color: white; /* default */
}
Div/section example with default header no change on content:
<div class="grid-30-span g-100vh">
<img
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1.414 1'%3E%3C/svg%3E"
data-src="/images/example_default_header.jpg"
class="lazyload"
alt="">
</div>
Div/section example change header on content:
<div class="grid-30-span g-100vh" data-color="white" data-background="darkblue">
<img
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1.414 1'%3E%3C/svg%3E"
data-src="/images/example_darkblue.jpg"
class="lazyload"
alt="">
</div>
<div class="grid-30-span g-100vh" data-color="white" data-background="black">
<img
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1.414 1'%3E%3C/svg%3E"
data-src="/images/example_black.jpg"
class="lazyload"
alt="">
</div>
Intersection Observer approach:
var mq = window.matchMedia( "(min-width: 568px)" );
if (mq.matches) {
// Add for mobile reset
document.addEventListener("DOMContentLoaded", function(event) {
// Add document load callback for leaving script in head
const header = document.querySelector('header');
const sections = document.querySelectorAll('div');
const config = {
rootMargin: '0px',
threshold: [0.00, 0.95]
};
const observer = new IntersectionObserver(function (entries, self) {
entries.forEach(entry => {
if (entry.isIntersecting) {
if (entry.intersectionRatio > 0.95) {
header.style.color = entry.target.dataset.color !== undefined ? entry.target.dataset.color : "black";
header.style.background = entry.target.dataset.background !== undefined ? entry.target.dataset.background : "white";
} else {
if (entry.target.getBoundingClientRect().top < 0 ) {
header.style.color = entry.target.dataset.color !== undefined ? entry.target.dataset.color : "black";
header.style.background = entry.target.dataset.background !== undefined ? entry.target.dataset.background : "white";
}
}
}
});
}, config);
sections.forEach(section => {
observer.observe(section);
});
});
}

Instead of listening to scroll event you should have a look at Intersection Observer (IO).
This was designed to solve problems like yours. And it is much more performant than listening to scroll events and then calculating the position yourself.
First, here is a codepen which shows a solution for your problem.
I am not the author of this codepen and I would maybe do some things a bit different but it definitely shows you the basic approach on how to solve your problem.
Things I would change: You can see in the example that if you scoll 99% to a new section, the heading changes even tough the new section is not fully visible.
Now with that out of the way, some explaining on how this works (note, I will not blindly copy-paste from codepen, I will also change const to let, but use whatever is more appropriate for your project.
First, you have to specify the options for IO:
let options = {
rootMargin: '-50px 0px -55%'
}
let observer = new IntersectionObserver(callback, options);
In the example the IO is executing the callback once an element is 50px away from getting into view. I can't recommend some better values from the top of my head but if I would have the time I would try to tweak these parameters to see if I could get better results.
In the codepen they define the callback function inline, I just wrote it that way to make it clearer on what's happening where.
Next step for IO is to define some elements to watch. In your case you should add some class to your divs, like <div class="section">
let entries = document.querySelectorAll('div.section');
entries.forEach(entry => {observer.observe(entry);})
Finally you have to define the callback function:
entries.forEach(entry => {
if (entry.isIntersecting) {
//specify what should happen if an element is coming into view, like defined in the options.
}
});
Edit: As I said this is just an example on how to get you started, it's NOT a finished solution for you to copy paste. In the example based on the ID of the section that get's visible the current element is getting highlighted. You have to change this part so that instead of setting the active class to, for example, third element you set the color and background-color depending on some attribute you set on the Element. I would recommend using data attributes for that.
Edit 2: Of course you can continue using just scroll events, the official Polyfill from W3C uses scroll events to emulate IO for older browsers.it's just that listening for scroll event and calculating position is not performant, especially if there are multiple elements. So if you care about user experience I really recommend using IO. Just wanted to add this answer to show what the modern solution for such a problem would be.
Edit 3: I took my time to create an example based on IO, this should get you started.
Basically I defined two thresholds: One for 20 and one for 90%. If the element is 90% in the viewport then it's save to assume it will cover the header. So I set the class for the header to the element that is 90% in view.
Second threshold is for 20%, here we have to check if the element comes from the top or from the bottom into view. If it's visible 20% from the top then it will overlap with the header.
Adjust these values and adapt the logic as you see.
const sections = document.querySelectorAll('div');
const config = {
rootMargin: '0px',
threshold: [.2, .9]
};
const observer = new IntersectionObserver(function (entries, self) {
entries.forEach(entry => {
if (entry.isIntersecting) {
var headerEl = document.querySelector('header');
if (entry.intersectionRatio > 0.9) {
//intersection ratio bigger than 90%
//-> set header according to target
headerEl.className=entry.target.dataset.header;
} else {
//-> check if element is coming from top or from bottom into view
if (entry.target.getBoundingClientRect().top < 0 ) {
headerEl.className=entry.target.dataset.header;
}
}
}
});
}, config);
sections.forEach(section => {
observer.observe(section);
});
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.g-100vh {
height: 100vh
}
header {
min-height: 50px;
position: fixed;
background-color: green;
width: 100%;
}
header.white-menu {
color: white;
background-color: black;
}
header.black-menu {
color: black;
background-color: white;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<header>
<p>Header Content </p>
</header>
<div class="grid-30-span g-100vh white-menu" style="background-color:darkblue;" data-header="white-menu">
<img
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1.414 1'%3E%3C/svg%3E"
data-src="/images/example_darkblue.jpg"
class="lazyload"
alt="<?php echo $title; ?>">
</div>
<div class="grid-30-span g-100vh black-menu" style="background-color:lightgrey;" data-header="black-menu">
<img
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1.414 1'%3E%3C/svg%3E"
data-src="/images/example_lightgrey.jpg"
class="lazyload"
alt="<?php echo $title; ?>">
</div>

I might not understand the question completely, but as for your example - you can solve it by using the mix-blend-mode css property without using javascript at all.
Example:
header {background: white; position: relative; height: 20vh;}
header h1 {
position: fixed;
color: white;
mix-blend-mode: difference;
}
div {height: 100vh; }
<header>
<h1>StudioX, Project Title, Category...</h1>
</header>
<div style="background-color:darkblue;"></div>
<div style="background-color:lightgrey;"></div>

I've encountered the same situation and the solution I implemented is very precise because it doesn't rely on percentages but on real elements' bounding boxes:
class Header {
constructor() {
this.header = document.querySelector("header");
this.span = this.header.querySelector('span');
this.invertedSections = document.querySelectorAll(".invertedSection");
window.addEventListener('resize', () => this.resetObserver());
this.resetObserver();
}
resetObserver() {
if (this.observer) this.observer.disconnect();
const {
top,
height
} = this.span.getBoundingClientRect();
this.observer = new IntersectionObserver(entries => this.observerCallback(entries), {
root: document,
rootMargin: `-${top}px 0px -${window.innerHeight - top - height}px 0px`,
});
this.invertedSections.forEach((el) => this.observer.observe(el));
};
observerCallback(entries) {
let inverted = false;
entries.forEach((entry) => {
if (entry.isIntersecting) inverted = true;
});
if (inverted) this.header.classList.add('inverted');
else this.header.classList.remove('inverted');
};
}
new Header();
header {
position: fixed;
top: 0;
left: 0;
right: 0;
padding: 20px 0;
text-transform: uppercase;
text-align: center;
font-weight: 700;
}
header.inverted {
color: #fff;
}
section {
height: 500px;
}
section.invertedSection {
background-color: #000;
}
<body>
<header>
<span>header</span>
</header>
<main>
<section></section>
<section class="invertedSection"></section>
<section></section>
<section class="invertedSection"></section>
</main>
</body>
What it does is actually quite simple: we can't use IntersectionObserver to know when the header and other elements are crossing (because the root must be a parent of the observed elements), but we can calculate the position and size of the header to add rootMargin to the observer.
Sometimes, the header is taller than its content (because of padding and other stuff) so I calculate the bounding-box of the span in the header (I want it to become white only when this element overlaps a black section).
Because the height of the window can change, I have to reset the IntersectionObserver on window resize.
The root property is set to document here because of iframe restrictions of the snippet (otherwise you can leave this field undefined).
With the rootMargin, I specify in which area I want the observer to look for intersections.
Then I observe every black section. In the callback function, I define if at least one section is overlapping, and if this is true, I add an inverted className to the header.
If we could use values like calc(100vh - 50px) in the rootMargin property, we may not need to use the resize listener.
We could even improve this system by adding side rootMargin, for instance if I have black sections that are only half of the window width and may or may not intersect with the span in the header depending on its horizontal position.

#Quentin D
I searched the internet for something like this, and I found this code to be the best solution for my needs.
Therefore I decided to build on it and create a universal "Observer" class, that can be used in many cases where IntesectionObserver is required, including changing the header styles.
I haven't tested it much, only in a few basic cases, and it worked for me. I haven't tested it on a page that has a horizontal scroll.
Having it this way makes it easy to use it, just save it as a .js file and include/import it in your code, something like a plugin. :)
I hope someone will find it useful.
If someone finds better ideas (especially for "horizontal" sites), it would be nice to see them here.
Edit: I hadn't made the correct "unobserve", so I fixed it.
/* The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.
ROOT:
It is not necessary for the root to be the ancestor element of the target. The root is allways the document, and the so-called root element is used only to get its size and position, to create an area in the document, with options.rootMargin.
Leave it false to have the viewport as root.
TARGET:
IntersectionObserver triggers when the target is entering at the specified ratio(s), and when it exits at the same ratio(s).
For more on IntersectionObserverEntry object, see:
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#targeting_an_element_to_be_observed
IntersectionObserverEntry.time // Timestamp when the change occurred
IntersectionObserverEntry.rootBounds // Unclipped area of root
IntersectionObserverEntry.intersectionRatio // Ratio of intersectionRect area to boundingClientRect area
IntersectionObserverEntry.target // the Element target
IntersectionObserverEntry.boundingClientRect // target.boundingClientRect()
IntersectionObserverEntry.intersectionRect // boundingClientRect, clipped by its containing block ancestors, and intersected with rootBounds
THRESHOLD:
Intersection ratio/threshold can be an array, and then it will trigger on each value, when in and when out.
If root element's size, for example, is only 10% of the target element's size, then intersection ratio/threshold can't be set to more than 10% (that is 0.1).
CALLBACKS:
There can be created two functions; when the target is entering and when it's exiting. These functions can do what's required for each event (visible/invisible).
Each function is passed three arguments, the root (html) element, IntersectionObserverEntry object, and intersectionObserver options used for that observer.
Set only root and targets to only have some info in the browser's console.
For more info on IntersectionObserver see: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
Polyfill: <script src="https://polyfill.io/v3/polyfill.js?features=IntersectionObserver"></script>
or:
https://github.com/w3c/IntersectionObserver/tree/main/polyfill
Based on answer by Quentin D, answered Oct 27 '20 at 12:12
https://stackoverflow.com/questions/57834100/change-style-header-nav-with-intersection-observer-io
root - (any selector) - root element, intersection parent (only the first element is selected).
targets - (any selector) - observed elements that trigger function when visible/invisible.
inCb - (function name) - custom callback function to trigger when the target is intersecting.
outCb - (function name) - custom callback function to trigger when the target is not intersecting.
thres - (number 0-1) - threshold to trigger the observer (e.g. 0.1 will trigger when 10% is visible).
unobserve- (bolean) - if true, the target is unobserved after triggering the callback.
EXAMPLE:
(place in 'load' event listener, to have the correct dimensions)
var invertedHeader = new Observer({
root: '.header--main', // don't set to have the viewport as root
targets: '[data-bgd-dark]',
thres: [0, .16],
inCb: someCustomFunction,
});
*/
class Observer {
constructor({
root = false,
targets = false,
inCb = this.isIn,
outCb = this.isOut,
thres = 0,
unobserve = false,
} = {}) {
// this element's position creates with rootMargin the area in the document
// which is used as intersection observer's root area.
// the real root is allways the document.
this.area = document.querySelector(root); // intersection area
this.targets = document.querySelectorAll(targets); // intersection targets
this.inCallback = inCb; // callback when intersecting
this.outCallback = outCb; // callback when not intersecting
this.unobserve = unobserve; // unobserve after intersection
this.margins; // rootMargin for observer
this.windowW = document.documentElement.clientWidth;
this.windowH = document.documentElement.clientHeight;
// intersection is being checked like:
// if (entry.isIntersecting || entry.intersectionRatio >= this.ratio),
// and if ratio is 0, "entry.intersectionRatio >= this.ratio" will be true,
// even for non-intersecting elements, therefore:
this.ratio = thres;
if (Array.isArray(thres)) {
for (var i = 0; i < thres.length; i++) {
if (thres[i] == 0) {
this.ratio[i] = 0.0001;
}
}
} else {
if (thres == 0) {
this.ratio = 0.0001;
}
}
// if root selected use its position to create margins, else no margins (viewport as root)
if (this.area) {
this.iArea = this.area.getBoundingClientRect(); // intersection area
this.margins = `-${this.iArea.top}px -${(this.windowW - this.iArea.right)}px -${(this.windowH - this.iArea.bottom)}px -${this.iArea.left}px`;
} else {
this.margins = '0px';
}
// Keep this last (this.ratio has to be defined before).
// targets are required to create an observer.
if (this.targets) {
window.addEventListener('resize', () => this.resetObserver());
this.resetObserver();
}
}
resetObserver() {
if (this.observer) this.observer.disconnect();
const options = {
root: null, // null for the viewport
rootMargin: this.margins,
threshold: this.ratio,
}
this.observer = new IntersectionObserver(
entries => this.observerCallback(entries, options),
options,
);
this.targets.forEach((target) => this.observer.observe(target));
};
observerCallback(entries, options) {
entries.forEach(entry => {
// "entry.intersectionRatio >= this.ratio" for older browsers
if (entry.isIntersecting || entry.intersectionRatio >= this.ratio) {
// callback when visible
this.inCallback(this.area, entry, options);
// unobserve
if (this.unobserve) {
this.observer.unobserve(entry.target);
}
} else {
// callback when hidden
this.outCallback(this.area, entry, options);
// No unobserve, because all invisible targets will be unobserved automatically
}
});
};
isIn(rootElmnt, targetElmt, options) {
if (!rootElmnt) {
console.log(`IO Root: VIEWPORT`);
} else {
console.log(`IO Root: ${rootElmnt.tagName} class="${rootElmnt.classList}"`);
}
console.log(`IO Target: ${targetElmt.target.tagName} class="${targetElmt.target.classList}" IS IN (${targetElmt.intersectionRatio * 100}%)`);
console.log(`IO Threshold: ${options.threshold}`);
//console.log(targetElmt.rootBounds);
console.log(`============================================`);
}
isOut(rootElmnt, targetElmt, options) {
if (!rootElmnt) {
console.log(`IO Root: VIEWPORT`);
} else {
console.log(`IO Root: ${rootElmnt.tagName} class="${rootElmnt.classList}"`);
}
console.log(`IO Target: ${targetElmt.target.tagName} class="${targetElmt.target.classList}" IS OUT `);
console.log(`============================================`);
}
}

This still needs adjustment, but you could try the following:
const header = document.getElementsByTagName('header')[0];
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
header.style.color = entry.target.dataset.color || '';
header.style.backgroundColor = entry.target.dataset.background;
}
});
}, { threshold: 0.51 });
[...document.getElementsByClassName('observed')].forEach((t) => {
t.dataset.background = t.dataset.background || window.getComputedStyle(t).backgroundColor;
observer.observe(t);
});
body {
font-family: arial;
margin: 0;
}
header {
border-bottom: 1px solid red;
margin: 0 auto;
width: 100vw;
display: flex;
justify-content: center;
position: fixed;
background: transparent;
transition: all 0.5s ease-out;
}
header div {
padding: 0.5rem 1rem;
border: 1px solid red;
margin: -1px -1px -1px 0;
}
.observed {
height: 100vh;
border: 1px solid black;
}
.observed:nth-of-type(2) {
background-color: grey;
}
.observed:nth-of-type(3) {
background-color: white;
}
<header>
<div>One</div>
<div>Two</div>
<div>Three</div>
</header>
<div class="observed">
<img src="http://placekitten.com/g/200/300">
<img src="http://placekitten.com/g/400/300">
</div>
<div class="observed" data-color="white" data-background="black">
<img src="http://placekitten.com/g/600/300">
</div>
<div class="observed" data-color="black" data-background="white">
<img src="http://placekitten.com/g/600/250">
</div>
The CSS ensures each observed section takes up 100vw and the observer does its thing when anyone of those comes into view by at least 51% percent.
In the callback the headers background-color is then set to the background-color of the intersecting element.

Related

Is it possible to add a class to an element when positioned sticky to the top of the window? [duplicate]

I'm using the new position: sticky (info) to create an iOS-like list of content.
It's working well and far superior than the previous JavaScript alternative (example) however as far as I know no event is fired when it's triggered, which means I can't do anything when the bar hits the top of the page, unlike with the previous solution.
I'd like to add a class (e.g. stuck) when an element with position: sticky hits the top of the page. Is there a way to listen for this with JavaScript? Usage of jQuery is fine.
Demo with IntersectionObserver (use a trick):
// get the sticky element
const stickyElm = document.querySelector('header')
const observer = new IntersectionObserver(
([e]) => e.target.classList.toggle('isSticky', e.intersectionRatio < 1),
{threshold: [1]}
);
observer.observe(stickyElm)
body{ height: 200vh; font:20px Arial; }
section{
background: lightblue;
padding: 2em 1em;
}
header{
position: sticky;
top: -1px; /* ➜ the trick */
padding: 1em;
padding-top: calc(1em + 1px); /* ➜ compensate for the trick */
background: salmon;
transition: .1s;
}
/* styles for when the header is in sticky mode */
header.isSticky{
font-size: .8em;
opacity: .5;
}
<section>Space</section>
<header>Sticky Header</header>
The top value needs to be -1px or the element will never intersect with the top of the browser window (thus never triggering the intersection observer).
To counter this 1px of hidden content, an additional 1px of space should be added to either the border or the padding of the sticky element.
πŸ’‘ Alternatively, if you wish to keep the CSS as is (top:0), then you can apply the "correction" at the intersection observer-level by adding the setting rootMargin: '-1px 0px 0px 0px' (as #mattrick showed in his answer)
Demo with old-fashioned scroll event listener:
auto-detecting first scrollable parent
Throttling the scroll event
Functional composition for concerns-separation
Event callback caching: scrollCallback (to be able to unbind if needed)
// get the sticky element
const stickyElm = document.querySelector('header');
// get the first parent element which is scrollable
const stickyElmScrollableParent = getScrollParent(stickyElm);
// save the original offsetTop. when this changes, it means stickiness has begun.
stickyElm._originalOffsetTop = stickyElm.offsetTop;
// compare previous scrollTop to current one
const detectStickiness = (elm, cb) => () => cb & cb(elm.offsetTop != elm._originalOffsetTop)
// Act if sticky or not
const onSticky = isSticky => {
console.clear()
console.log(isSticky)
stickyElm.classList.toggle('isSticky', isSticky)
}
// bind a scroll event listener on the scrollable parent (whatever it is)
// in this exmaple I am throttling the "scroll" event for performance reasons.
// I also use functional composition to diffrentiate between the detection function and
// the function which acts uppon the detected information (stickiness)
const scrollCallback = throttle(detectStickiness(stickyElm, onSticky), 100)
stickyElmScrollableParent.addEventListener('scroll', scrollCallback)
// OPTIONAL CODE BELOW ///////////////////
// find-first-scrollable-parent
// Credit: https://stackoverflow.com/a/42543908/104380
function getScrollParent(element, includeHidden) {
var style = getComputedStyle(element),
excludeStaticParent = style.position === "absolute",
overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/;
if (style.position !== "fixed")
for (var parent = element; (parent = parent.parentElement); ){
style = getComputedStyle(parent);
if (excludeStaticParent && style.position === "static")
continue;
if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX))
return parent;
}
return window
}
// Throttle
// Credit: https://jsfiddle.net/jonathansampson/m7G64
function throttle (callback, limit) {
var wait = false; // Initially, we're not waiting
return function () { // We return a throttled function
if (!wait) { // If we're not waiting
callback.call(); // Execute users function
wait = true; // Prevent future invocations
setTimeout(function () { // After a period of time
wait = false; // And allow future invocations
}, limit);
}
}
}
header{
position: sticky;
top: 0;
/* not important styles */
background: salmon;
padding: 1em;
transition: .1s;
}
header.isSticky{
/* styles for when the header is in sticky mode */
font-size: .8em;
opacity: .5;
}
/* not important styles*/
body{ height: 200vh; font:20px Arial; }
section{
background: lightblue;
padding: 2em 1em;
}
<section>Space</section>
<header>Sticky Header</header>
Here's a React component demo which uses the first technique
I found a solution somewhat similar to #vsync's answer, but it doesn't require the "hack" that you need to add to your stylesheets. You can simply change the boundaries of the IntersectionObserver to avoid needing to move the element itself outside of the viewport:
const observer = new IntersectionObserver(callback, {
rootMargin: '-1px 0px 0px 0px',
threshold: [1],
});
observer.observe(element);
If anyone gets here via Google one of their own engineers has a solution using IntersectionObserver, custom events, and sentinels:
https://developers.google.com/web/updates/2017/09/sticky-headers
Just use vanilla JS for it. You can use throttle function from lodash to prevent some performance issues as well.
const element = document.getElementById("element-id");
document.addEventListener(
"scroll",
_.throttle(e => {
element.classList.toggle(
"is-sticky",
element.offsetTop <= window.scrollY
);
}, 500)
);
After Chrome added position: sticky, it was found to be not ready enough and relegated to to --enable-experimental-webkit-features flag. Paul Irish said in February "feature is in a weird limbo state atm".
I was using the polyfill until it become too much of a headache. It works nicely when it does, but there are corner cases, like CORS problems, and it slows page loads by doing XHR requests for all your CSS links and reparsing them for the "position: sticky" declaration that the browser ignored.
Now I'm using ScrollToFixed, which I like better than StickyJS because it doesn't mess up my layout with a wrapper.
There is currently no native solution. See Targeting position:sticky elements that are currently in a 'stuck' state. However I have a CoffeeScript solution that works with both native position: sticky and with polyfills that implement the sticky behavior.
Add 'sticky' class to elements you want to be sticky:
.sticky {
position: -webkit-sticky;
position: -moz-sticky;
position: -ms-sticky;
position: -o-sticky;
position: sticky;
top: 0px;
z-index: 1;
}
CoffeeScript to monitor 'sticky' element positions and add the 'stuck' class when they are in the 'sticky' state:
$ -> new StickyMonitor
class StickyMonitor
SCROLL_ACTION_DELAY: 50
constructor: ->
$(window).scroll #scroll_handler if $('.sticky').length > 0
scroll_handler: =>
#scroll_timer ||= setTimeout(#scroll_handler_throttled, #SCROLL_ACTION_DELAY)
scroll_handler_throttled: =>
#scroll_timer = null
#toggle_stuck_state_for_sticky_elements()
toggle_stuck_state_for_sticky_elements: =>
$('.sticky').each ->
$(this).toggleClass('stuck', this.getBoundingClientRect().top - parseInt($(this).css('top')) <= 1)
NOTE: This code only works for vertical sticky position.
I came up with this solution that works like a charm and is pretty small. :)
No extra elements needed.
It does run on the window scroll event though which is a small downside.
apply_stickies()
window.addEventListener('scroll', function() {
apply_stickies()
})
function apply_stickies() {
var _$stickies = [].slice.call(document.querySelectorAll('.sticky'))
_$stickies.forEach(function(_$sticky) {
if (CSS.supports && CSS.supports('position', 'sticky')) {
apply_sticky_class(_$sticky)
}
})
}
function apply_sticky_class(_$sticky) {
var currentOffset = _$sticky.getBoundingClientRect().top
var stickyOffset = parseInt(getComputedStyle(_$sticky).top.replace('px', ''))
var isStuck = currentOffset <= stickyOffset
_$sticky.classList.toggle('js-is-sticky', isStuck)
}
Note: This solution doesn't take elements that have bottom stickiness into account. This only works for things like a sticky header. It can probably be adapted to take bottom stickiness into account though.
I know it has been some time since the question was asked, but I found a good solution to this. The plugin stickybits uses position: sticky where supported, and applies a class to the element when it is 'stuck'. I've used it recently with good results, and, at time of writing, it is active development (which is a plus for me) :)
I'm using this snippet in my theme to add .is-stuck class to .site-header when it is in a stuck position:
// noinspection JSUnusedLocalSymbols
(function (document, window, undefined) {
let windowScroll;
/**
*
* #param element {HTMLElement|Window|Document}
* #param event {string}
* #param listener {function}
* #returns {HTMLElement|Window|Document}
*/
function addListener(element, event, listener) {
if (element.addEventListener) {
element.addEventListener(event, listener);
} else {
// noinspection JSUnresolvedVariable
if (element.attachEvent) {
element.attachEvent('on' + event, listener);
} else {
console.log('Failed to attach event.');
}
}
return element;
}
/**
* Checks if the element is in a sticky position.
*
* #param element {HTMLElement}
* #returns {boolean}
*/
function isSticky(element) {
if ('sticky' !== getComputedStyle(element).position) {
return false;
}
return (1 >= (element.getBoundingClientRect().top - parseInt(getComputedStyle(element).top)));
}
/**
* Toggles is-stuck class if the element is in sticky position.
*
* #param element {HTMLElement}
* #returns {HTMLElement}
*/
function toggleSticky(element) {
if (isSticky(element)) {
element.classList.add('is-stuck');
} else {
element.classList.remove('is-stuck');
}
return element;
}
/**
* Toggles stuck state for sticky header.
*/
function toggleStickyHeader() {
toggleSticky(document.querySelector('.site-header'));
}
/**
* Listen to window scroll.
*/
addListener(window, 'scroll', function () {
clearTimeout(windowScroll);
windowScroll = setTimeout(toggleStickyHeader, 50);
});
/**
* Check if the header is not stuck already.
*/
toggleStickyHeader();
})(document, window);
#vsync 's excellent answer was almost what I needed, except I "uglify" my code via Grunt, and Grunt requires some older JavaScript code styles. Here is the adjusted script I used instead:
var stickyElm = document.getElementById('header');
var observer = new IntersectionObserver(function (_ref) {
var e = _ref[0];
return e.target.classList.toggle('isSticky', e.intersectionRatio < 1);
}, {
threshold: [1]
});
observer.observe( stickyElm );
The CSS from that answer is unchanged
Something like this also works for a fixed scroll height:
// select the header
const header = document.querySelector('header');
// add an event listener for scrolling
window.addEventListener('scroll', () => {
// add the 'stuck' class
if (window.scrollY >= 80) navbar.classList.add('stuck');
// remove the 'stuck' class
else navbar.classList.remove('stuck');
});

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!

Determine if element is behind another

Is there a way to determine whether elementA is "behind" another element and thus elementA would not be visible to the user?
Obviously it's possible to do with stacking context, but the thing is that we do not know which elements we should be looking at. Therefore we would have to iterate through all the elements in DOM and perform stacking context comparison for multiple elements. That is not good in terms of performance.
Here's a jsfiddle. So is there a way to determine that #hidden-element is not visible to the user, because another element is rendered on top of it?
https://jsfiddle.net/b9dek40b/5/
HTML:
<div id="covering-element"></div>
<div>
<div id="hidden-element"></div>
</div>
Styles:
#covering-element {
position: absolute;
width: 100px;
height: 100px;
background: darksalmon;
text-align: center;
}
#hidden-element {
width: 25px;
height: 25px;
background: deeppink;
}
Our solution was to use couple things to determine whether the element is visible and not behind any other elements. Here's the methods we used.
window.getComputedStyle to check visibility:hidden and display:none
document.elementFromPoint from multiple points. Most common cases could probably be handled by checking all the corners. Though we needed more points to get more robust results. Corner coordinates can be easily checked with Element.getBoundingClientRect()
https://jsfiddle.net/k591Lbwu/27/
HTML
<div id="covering-element"></div>
<div>
<div id="hidden-element"></div>
</div>
<button style="margin-top:100px">Check visibility</button>
CSS
#covering-element {
position: absolute;
width: 100px;
height: 100px;
background: darksalmon;
text-align: center;
}
#hidden-element {
width: 25px;
height: 25px;
background: deeppink;
}
JavaScript
document.querySelector('button').addEventListener('click', function() {
const element = document.getElementById('hidden-element')
alert('Visible = '+isVisible(element))
})
function isVisible(element) {
if(!isVisibleByStyles(element)) return false
if(isBehindOtherElement(element)) return false
return true
}
function isVisibleByStyles(element) {
const styles = window.getComputedStyle(element)
return styles.visibility !== 'hidden' && styles.display !== 'none'
}
function isBehindOtherElement(element) {
const boundingRect = element.getBoundingClientRect()
// adjust coordinates to get more accurate results
const left = boundingRect.left + 1
const right = boundingRect.right - 1
const top = boundingRect.top + 1
const bottom = boundingRect.bottom - 1
if(document.elementFromPoint(left, top) !== element) return true
if(document.elementFromPoint(right, top) !== element) return true
if(document.elementFromPoint(left, bottom) !== element) return true
if(document.elementFromPoint(right, bottom) !== element) return true
return false
}

Check if scrolled past div with JavaScript (no jQuery)

I am currently learning JavaScript and all the solutions that I've come across use the jQuery library. Is there a way to do it, just using pure JavaScript?
The idea is to have something like:
function passed(element) {if passed: do something}
Listen for the scroll event. To find the current scroll position, you can call the scollY method.
To get the Y coordinate of the top of an element, you can use the element's offsetTop. Because the element has a height, we want to add the height to our calculation.
That's it.
window.addEventListener("scroll", function() {
var elementTarget = document.getElementById("section-2");
if (window.scrollY > (elementTarget.offsetTop + elementTarget.offsetHeight)) {
alert("You've scrolled past the second div");
}
});
.section-1 {
height: 400px;
width: 100%;
background: green;
}
.section-3 {
height: 400px;
width: 100%;
background: orange;
}
<div class="section-1"></div>
<div id="section-2">Scroll past this div</div>
<div class="section-3"></div>
You should be able to use the following:
if(window.scrollY >(element.offsetHeight + element.offsetTop)){
// do something
}
With https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetTop you can get the Y coordinate of an element.
With https://developer.mozilla.org/en-US/docs/Web/API/Window/scrollY you can get the current Y coordinate of the scroll.
With https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetHeight you get the height of an element.
So the only thing that remains to do is to check if scrollY > (offsetHeight + offsetTop). If this is true, you passed the element with the scroll.
I leave to you the implementation, as a practice to learn Javascript ;)
if (element.getBoundingClientRect().y < 0) {
// do something
}
This can be achieved with the IntersectionObserver API without having to rely on scroll events at all.
const elementTarget = document.getElementById("section-2");
// skip first callback when first observing
let firstCallback = true;
const observer = new IntersectionObserver(entries => {
if (!entries[0].isIntersecting) {
if (firstCallback) {
firstCallback = false;
} else {
alert("You've scrolled past the second div");
}
}
});
observer.observe(elementTarget);
// remember to unobserve when done
.section-1 {
height: 1000px;
width: 100%;
background: green;
}
.section-3 {
height: 1000px;
width: 100%;
background: orange;
}
<div class="section-1"></div>
<div id="section-2">Scroll past this div</div>
<div class="section-3"></div>

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