I have a script that changes the class of some elements from "hidden" to "show" in order to add a cool "visible on scroll" effect. The issue is, after the page loads and the page has been scrolled the animations happen again when scrolling back up making it look clunky, and to make it worse if an element borders the visible/non-visible section of the screen it has a spasm that loops the animation constantly.
This is the current script :
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
console.log(entry);
if (entry.isIntersecting) {
entry.target.classList.add("show");
} else {
entry.target.classList.remove("show");
}
});
});
const hiddenElements = document.querySelectorAll(".hidden");
hiddenElements.forEach((el) => observer.observe(el));
How do I make this happen just once and get rid of the junky spasms? Thanks for your help!
unobserve your elements:
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
console.log(entry);
if (entry.isIntersecting) {
entry.target.classList.add("show");
//this stops the observer on the intersecting element:
observer.unobserve(entry.target);
} else {
entry.target.classList.remove("show");
}
});
});
const hiddenElements = document.querySelectorAll(".hidden");
hiddenElements.forEach((el) => observer.observe(el));
I'm appending a DOM element like this:
this.store.state.runtime.UIWrap.appendChild(newElement)
When I immediately try to measure the new element's width I get 2.
I tried:
setTimeout()
double nested window.requestAnimationFrame()
MutationObserver
The above works very unreliably, like 50% of the time. Only when I set a large timeout like 500ms it worked.
This happens only on mobile.
This is the workaround that I'm using, but it's ugly:
function getWidthFromStyle(el) {
return parseFloat(getComputedStyle(el, null).width.replace('px', ''))
}
function getWidthFromBoundingClientRect(el) {
return el.getBoundingClientRect().width
}
console.log(getWidthFromBoundingClientRect(newElement))
while (getWidthFromBoundingClientRect(newElement) < 50) {
await new Promise(r => setTimeout(r, 500))
console.log(getWidthFromBoundingClientRect(newElement))
}
I tried with both functions getWidthFromStyle() and getWidthFromBoundingClientRect() - no difference. The width gets calculated properly after a couple of cycles.
I also tried using MutationObserver without success.
Is there a way to know when the DOM is fully updated and styled before I try to measure an element's width/height?
P.S. I'm not using any framework. this.store.state.runtime... is my own implementation of a Store, similar to Vue.
EDIT: The size of the element depended on an image inside it and I was trying to measure the element before the image had loaded. Silly.
it can done with MutationObserver.
doesn't this method solve your problem?
const div = document.querySelector("div");
const span = document.querySelector("span");
const observer = new MutationObserver(function () {
console.log("new width", size());
});
observer.observe(div, { subtree: true, childList: true });
function addElem() {
setTimeout(() => {
const newSpan = document.createElement("span");
newSpan.innerHTML = "second";
div.appendChild(newSpan);
console.log("element added");
}, 3000);
}
function size() {
return div.getBoundingClientRect().width;
}
console.log("old width", size());
addElem();
div {
display: inline-block;
border: 1px dashed;
}
span {
background: gold;
}
<div>
<span>one</span>
</div>
You can use something like this:
export function waitElement(elementId) {
return new Promise((resolve, reject) => {
const element = document.getElementById(elementId);
if (element) {
resolve(element);
} else {
let tries = 10;
const interval = setInterval(() => {
const element = document.getElementById(elementId);
if (element) {
clearInterval(interval);
resolve(element);
}
if (tries-- < 0) {
clearInterval(interval);
reject(new Error("Element not found"));
}
}, 100);
}
});
}
I am trying to work with the Intersection Observer API. I have a function which works in my first iteration. The basic logic is that if the user scrolls down and adds or removes items from a basket, once the basket is in view again (as it is at the top of the document) then I fire an API call.
The issue is that it will not fire the function before scrolling, I want to trigger it if the item is visible or becomes visible again after scrolling (the second part is working)
Here is original js:
var observerTargets = document.querySelectorAll('[id^="mini-trolley"]');
var observerOptions = {
root: null, // null means root is viewport
rootMargin: '0px',
threshold: 0.01 // trigger callback when 1% of the element is visible
}
var activeClass = 'active';
var trigger = $('button');
var isCartItemClicked = false;
trigger.on('click', function() {
isCartItemClicked = true;
});
function observerCallback(entries, observer) {
entries.forEach(entry => {
if(entry.isIntersecting && isCartItemClicked){
$(observerTargets).removeClass(activeClass);
$(entry.target).addClass(activeClass);
isCartItemClicked = false;
console.log('isCartItemClicked and in view');
// do my api call function here
} else {
$(entry.target).removeClass(activeClass);
}
});
}
var observer = new IntersectionObserver(observerCallback, observerOptions);
[...observerTargets].forEach(target => observer.observe(target));
I have updated this so it now checks if the item is visible. so I have updated:
if(entry.isIntersecting && isCartItemClicked)
to
if((entry.isVisible || entry.isIntersecting) && isCartItemClicked)
The issue as I understand is that the observer is only triggered on scroll, but the entry.isVisible is part of the observer callback function.
I have made a JSFIDDLE here (which has HTML and CSS markup).
Is it possible to modify the code. Weirdly the MDN page does not mention the isVisible property, but it is clearly part of the function.
This one is a little tricky but can be done by creating a someObserverEntriesVisible parameter that is set by the observerCallback. With that in place we can define how the button triggers should be handled separately from the observer callback for each intersecting entry.
const observerTargets = document.querySelectorAll('[id^="mini-trolley"]');
const observerOptions = {
root: null, // null means root is viewport
rootMargin: '0px',
threshold: 0.01 // trigger callback when 1% of the element is visible
};
const activeClass = 'active';
const trigger = $('button');
let isCartItemClicked = false;
let someObserverEntriesVisible = null;
let observerEntries = [];
trigger.on('click', () => {
isCartItemClicked = true;
if (someObserverEntriesVisible) {
console.log('fired from button');
observerCallback(observerEntries, observer, false);
}
});
function observerCallback(entries, observer, resetCartItemClicked = true) {
observerEntries = entries;
someObserverEntriesVisible = false;
entries.forEach(entry => {
someObserverEntriesVisible ||= entry.isIntersecting;
if (entry.isIntersecting && isCartItemClicked) {
$(entry.target).addClass(activeClass);
// add API call here
if (resetCartItemClicked) {
isCartItemClicked = false;
console.log('fired from observer');
}
} else {
$(entry.target).removeClass(activeClass);
}
});
}
const observer = new IntersectionObserver(observerCallback, observerOptions);
[...observerTargets].forEach(target => observer.observe(target));
#content {
height: 500px;
}
.active {
background-color: orange;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div id="mini-trolley">Observer target1</div>
<button>Top button</button>
<div id="content"></div>
<div id="mini-trolley">Observer target2</div>
<button>Bottom button</button>
I have a react app, which renders the data on the screen. The URL is of the format <DOMAIN>/slug#entityId. Thus, after the view is loaded, I need to scroll to that specific #entityId provided as the id of the HTML element.
I am using React's componentDidUpdate() method to scroll to the ID after the render occurs.
componentDidUpdate () {
if(// data rendered into view) {
const id = this.entityId;
if (id) {
setTimeout(() => {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({
behavior: 'smooth'
});
}
}, 500);
}
}
}
Thus, the scroll to the ID happens before the images are loaded. This leads to the scroll not stopping at the expected place. The number of images is their resolution is variable.
Increasing the timeout to 1000ms does solve the issue. But is this an optimal solution?
with document ready
$(document).ready(function () {
if(// data rendered into view) {
const id = this.entityId;
if (id) {
setTimeout(() => {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({
behavior: 'smooth'
});
}
}, 500);
}
}
}
});
I have a website with different sections. I am using segment.io to track different actions on the page. How can I detect if a user has scrolled to the bottom of a div? I have tried the following but it seems to be triggered as soon as I scroll on the page and not when
I reached the bottom of the div.
componentDidMount() {
document.addEventListener('scroll', this.trackScrolling);
}
trackScrolling = () => {
const wrappedElement = document.getElementById('header');
if (wrappedElement.scrollHeight - wrappedElement.scrollTop === wrappedElement.clientHeight) {
console.log('header bottom reached');
document.removeEventListener('scroll', this.trackScrolling);
}
};
An even simpler way to do it is with scrollHeight, scrollTop, and clientHeight.
Subtract the scrolled height from the total scrollable height. If this is equal to the visible area, you've reached the bottom!
element.scrollHeight - element.scrollTop === element.clientHeight
In react, just add an onScroll listener to the scrollable element, and use event.target in the callback.
class Scrollable extends Component {
handleScroll = (e) => {
const bottom = e.target.scrollHeight - e.target.scrollTop === e.target.clientHeight;
if (bottom) { ... }
}
render() {
return (
<ScrollableElement onScroll={this.handleScroll}>
<OverflowingContent />
</ScrollableElement>
);
}
}
I found this to be more intuitive because it deals with the scrollable element itself, not the window, and it follows the normal React way of doing things (not using ids, ignoring DOM nodes).
You can also manipulate the equation to trigger higher up the page (lazy loading content/infinite scroll, for example).
you can use el.getBoundingClientRect().bottom to check if the bottom has been viewed
isBottom(el) {
return el.getBoundingClientRect().bottom <= window.innerHeight;
}
componentDidMount() {
document.addEventListener('scroll', this.trackScrolling);
}
componentWillUnmount() {
document.removeEventListener('scroll', this.trackScrolling);
}
trackScrolling = () => {
const wrappedElement = document.getElementById('header');
if (this.isBottom(wrappedElement)) {
console.log('header bottom reached');
document.removeEventListener('scroll', this.trackScrolling);
}
};
Here's a solution using React Hooks and ES6:
import React, { useRef, useEffect } from 'react';
const MyListComponent = () => {
const listInnerRef = useRef();
const onScroll = () => {
if (listInnerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = listInnerRef.current;
if (scrollTop + clientHeight === scrollHeight) {
// TO SOMETHING HERE
console.log('Reached bottom')
}
}
};
return (
<div className="list">
<div className="list-inner" onScroll={() => onScroll()} ref={listInnerRef}>
{/* List items */}
</div>
</div>
);
};
export default List;
This answer belongs to Brendan, let's make it functional
export default () => {
const handleScroll = (e) => {
const bottom = e.target.scrollHeight - e.target.scrollTop === e.target.clientHeight;
if (bottom) {
console.log("bottom")
}
}
return (
<div onScroll={handleScroll} style={{overflowY: 'scroll', maxHeight: '400px'}} >
//overflowing elements here
</div>
)
}
If the first div is not scrollable it won't work and onScroll didn't work for me in a child element like div after the first div so onScroll should be at the first HTML tag that has an overflow
We can also detect div's scroll end by using ref.
import React, { Component } from 'react';
import {withRouter} from 'react-router-dom';
import styles from 'style.scss';
class Gallery extends Component{
paneDidMount = (node) => {
if(node) {
node.addEventListener("scroll", this.handleScroll.bind(this));
}
}
handleScroll = (event) => {
var node = event.target;
const bottom = node.scrollHeight - node.scrollTop === node.clientHeight;
if (bottom) {
console.log("BOTTOM REACHED:",bottom);
}
}
render() {
var that = this;
return(<div className={styles.gallery}>
<div ref={that.paneDidMount} className={styles.galleryContainer}>
...
</div>
</div>);
}
}
export default withRouter(Gallery);
Extending chandresh's answer to use react hooks and ref I would do it like this;
import React, {useState, useEffect} from 'react';
export default function Scrollable() {
const [referenceNode, setReferenceNode] = useState();
const [listItems] = useState(Array.from(Array(30).keys(), (n) => n + 1));
useEffect(() => {
return () => referenceNode.removeEventListener('scroll', handleScroll);
}, []);
function handleScroll(event) {
var node = event.target;
const bottom = node.scrollHeight - node.scrollTop === node.clientHeight;
if (bottom) {
console.log('BOTTOM REACHED:', bottom);
}
}
const paneDidMount = (node) => {
if (node) {
node.addEventListener('scroll', handleScroll);
setReferenceNode(node);
}
};
return (
<div
ref={paneDidMount}
style={{overflowY: 'scroll', maxHeight: '400px'}}
>
<ul>
{listItems.map((listItem) => <li>List Item {listItem}</li>)}
</ul>
</div>
);
}
Add following functions in your React.Component and you're done :]
componentDidMount() {
window.addEventListener("scroll", this.onScroll, false);
}
componentWillUnmount() {
window.removeEventListener("scroll", this.onScroll, false);
}
onScroll = () => {
if (this.hasReachedBottom()) {
this.props.onScrollToBottom();
}
};
hasReachedBottom() {
return (
document.body.offsetHeight + document.body.scrollTop ===
document.body.scrollHeight
);
}
I know this has already been answered but, I think another good solution is to use what's already available out in the open source community instead of DIY. React Waypoints is a library that exists to solve this very problem. (Though don't ask me why the this problem space of determining if a person scrolls past an HTML element is called "waypoints," haha)
I think it's very well designed with its props contract and definitely encourage you to check it out.
I used follow in my code
.modify-table-wrap {
padding-top: 50px;
height: 100%;
overflow-y: scroll;
}
And add code in target js
handleScroll = (event) => {
const { limit, offset } = this.state
const target = event.target
if (target.scrollHeight - target.scrollTop === target.clientHeight) {
this.setState({ offset: offset + limit }, this.fetchAPI)
}
}
return (
<div className="modify-table-wrap" onScroll={this.handleScroll}>
...
<div>
)
Put a div with 0 height after your scrolling div. then use this custom hooks to detect if this div is visible.
const bottomRef = useRef();
const reachedBottom = useCustomHooks(bottomRef);
return(
<div>
{search resault}
</div>
<div ref={bottomRef}/> )
reachedBottom will toggle to true if you reach bottom
To evaluate whether my browser has scrolled to the bottom of a div, I settled with this solution:
const el = document.querySelector('.your-element');
const atBottom = Math.ceil(el.scrollTop + el.offsetHeight) === el.scrollHeight;
The solution below works fine on most of browsers but has problem with some of them.
element.scrollHeight - element.scrollTop === element.clientHeight
The better and most accurate is to use the code below which works on all browsers.
Math.abs(e.target.scrollHeight - e.target.clientHeight - e.target.scrollTop) < 1
So the final code should be something like this
const App = () => {
const handleScroll = (e) => {
const bottom = Math.abs(e.target.scrollHeight - e.target.clientHeight - e.target.scrollTop) < 1;
if (bottom) { ... }
}
return(
<div onScroll={handleScroll}>
...
</div>
)
}
This answer belongs to Brendan, but I am able to use that code in this way.
window.addEventListener("scroll", (e) => {
const bottom =
e.target.scrollingElement.scrollHeight -
e.target.scrollingElement.scrollTop ===
e.target.scrollingElement.clientHeight;
console.log(e);
console.log(bottom);
if (bottom) {
console.log("Reached bottom");
}
});
While others are able to access directly inside target by
e.target.scrollHeight, I am able to achieve same by
e.target.scrollingElement.scrollHeight