Same intersection observer for multiple HTML elements - javascript

I am trying to make it so that as some text items stop overlapping a dark background, they will individually change color one by one as the user scrolls. All of the text items are position: fixed
EDIT: The MDN docs say (emphasis mine):
The Intersection Observer API provides a way to asynchronously observe
changes in the intersection of a target element with an ancestor
element
I think this means there is no way to solve my problem because the elements I want to monitor for overlap are not children of the root I am specifying in the options object.
Is there any way to detect overlap if the overlapping element is not a child of the other element?
if ("IntersectionObserver" in window) {
const options = {
root: document.getElementById("flow-landing"),
rootMargin: "0px",
threshold: 0,
};
var callback = function (entries, observer) {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.style.color = "white";
} else {
entry.target.style.color = null;
}
});
};
const observer = new IntersectionObserver(callback, options);
var targets = [
Array.from(document.querySelectorAll(".social-item")),
Array.from(document.querySelectorAll(".additional-item")),
].flat();
targets.forEach((target) => observer.observe(target));
}
There aren't any console errors but the code isn't doing anything.

Modifying Ruslan's answer a little because in his answer, multiple Intersection Observer objects are being created.
It is possible to observe multiple elements using the same observer by calling .observe() on multiple elements.
let observerOptions = {
rootMargin: '0px',
threshold: 0.5
}
var observer = new IntersectionObserver(observerCallback, observerOptions);
function observerCallback(entries, observer) {
entries.forEach(entry => {
if(entry.isIntersecting) {
//do something
}
});
};
let target = '.targetSelector';
document.querySelectorAll(target).forEach((i) => {
if (i) {
observer.observe(i);
}
});

you can do something like that, at least it helps me:
document.querySelectorAll('.social-item').forEach((i) => {
if (i) {
const observer = new IntersectionObserver((entries) => {
observerCallback(entries, observer, i)
},
{threshold: 1});
observer.observe(i);
}
})
const observerCallback = (entries, observer, header) => {
entries.forEach((entry, i) => {
if (entry.isIntersecting) {
entry.target.style.color = "white";
}
else {
entry.target.style.color = null;
}
});
};

You could use the offsetTop and offsetHeight properties instead of the IntersectionObserver API.
For example, when the user scrolls, you can check to see if the offsetTop of element 2 is greater than the offsetTop and less than the offsetHeight of element 1.
WARNING: use debounce because the scroll event's handler function will be called once for every pixel the user scrolls, just imagine the performance nightmare that would occur if user scrolled 600-1000 pixels.
The LoDash documentation, describes it's debounce function as:
"[a function that] creates a debounced function that delays invoking func (handler) until after wait (time) milliseconds have elapsed since the last time the debounced function was invoked."
If you aren't using LoDash, here is a Vanilla JavaScript debounce function:
function debounce(handler, time) {
var timeout;
return function() {
var self = this, args = arguments;
clearTimeout(timeout);
timeout = setTimeout(function() {
return handler.apply(self, args);
}, time);
};
}
Here is the code that'll allow you to "do stuff" if element 2 "intersects" element 1.
let element_1 = document.querySelector("#my-element-1");
let element_2 = document.querySelector("#my-element-2");
window.addEventListener("scroll", debounce(() => {
if(element_2.offsetTop > element_1.offsetTop && element_2.offsetTop < element_1.offsetHeight) {
console.log("The elements are intersecting.");
}
}, 100));
In case that looks complex or hard to read, here is the same code, broken up into smaller chunks:
let element_1 = document.querySelector("#my-element-1");
let element_2 = document.querySelector("#my-element-2");
window.addEventListener("scroll", debounce(() => {
let x = element_2.offsetTop > element_1.offsetTop;
let y = element_2.offsetTop < element_1.offsetHeight;
if(x && y) {
console.log("The elements are intersecting.");
}
}, 250));
Notes & information:
you could use the >= and <= operators instead of the > and < operators
a wait time that's too long could make the effect look unnatural and forced.
Good luck.

Related

Intersection Observer and removing the observed element

I'm using intersection observer in a marquee, where I am observing for when the first child leaves the viewport, removing that first child, shifting the new (identical) first child by its element's width to the right (with marginLeft), and then appending another identical child element to the end of the parent div's children.
This works once, but intersection observer does not fire a second time, even though the new first child is identical to the deleted one.
I was wondering whether mutation observer would be more useful here, but not sure how to go about it!
let firstChild = document.querySelector('.marquee').firstElementChild;
let target = firstChild;
let options = {
root: null,
threshold: 0,
};
function onChange(changes) {
changes.forEach(change => {
if (change.intersectionRatio === 0) {
firstChild.remove();
console.log('Header is outside viewport');
for ( i = 0; i < 100000; i++ ) {
if ( rectParent.childElementCount < 3 ) {
let child = document.createElement('p');
child.className = "global-chat";
child.innerHTML = "Something written here";
let docFrag2 = document.createDocumentFragment().appendChild(child.cloneNode(true));
rectParent.appendChild(docFrag2);
document.querySelector('.marquee').firstElementChild.style.marginLeft = `${boxWidth}` * `${i+1}` + "px";
}
}
}
});
}
let observer = new IntersectionObserver(onChange, options);
observer.observe(target);```
The Problem
you define the IntersectionObserver class and instantiate it in the observer. at this moment, the firstChild variable gets the proper element, but in the next change, your firstChild didn't get an update!
The solution
You can update your firstChild element in the onChange function to ensure get the proper element before changes.
for example:
function onChange(changes) {
changes.forEach(change => {
if (change.intersectionRatio === 0) {
const firstChild = document.querySelector('.marquee').firstElementChild;
firstChild.remove();
// rest of the codes ...
}
});
}

JavaScript add and removeEventListeners callback with parameters

I'm trying to implement parallax with intersectionObserver API which calls eventListener scroll function with callback. The problem is what I want to pass parameter "entry.target" to add and removeEventListener functions, is it possible to remove event listener callback with parameter?.
Currently I have:
const parallaxScroll = trg => {
trg.style.top = `${-trg.getBoundingClientRect().top / 7.3}px`;
}
const addParallax = ([entry]) => {
if(entry.isIntersecting){
window.addEventListener('scroll', parallaxScroll);
//what I want to:
window.addEventListener('scroll', parallaxScroll(entry.target));
}else{
window.removeEventListener('scroll', parallaxScroll);
//what I want to:
window.removeEventListener('scroll', parallaxScroll(entry.target));
}
}
You would need to create a wrapper function (or call .bind to do the same) for each parameter. Assuming that you can have several entry objects that each could have an associated active scroll-listener, but not more than one per entry, you could dedicate a property of entry for storing that listener reference:
const addParallax = ([entry]) => {
if(entry.isIntersecting) {
if (entry.listener) return; // already have an active listener for this entry
entry.listener = () => parallaxScroll(entry.target);
window.addEventListener('scroll', entry.listener);
} else {
window.removeEventListener('scroll', entry.listener);
entry.listener = null;
}
}
Or with .bind:
const addParallax = ([entry]) => {
if(entry.isIntersecting) {
if (entry.listener) return; // already have an active listener for this entry
entry.listener = parallaxScroll.bind(null, entry.target);
window.addEventListener('scroll', entry.listener);
} else {
window.removeEventListener('scroll', entry.listener);
entry.listener = null;
}
}

Detect scroll direction in React js

I'm trying to detect if the scroll event is up or down but I can't find the solution.
import React, { useState, useEffect } from "react";
import { Link } from "react-router-dom";
const Navbar = ({ className }) => {
const [y, setY] = useState(0);
const handleNavigation = (e) => {
const window = e.currentTarget;
if (y > window.scrollY) {
console.log("scrolling up");
} else if (y < window.scrollY) {
console.log("scrolling down");
}
setY(window.scrollY);
};
useEffect(() => {
setY(window.scrollY);
window.addEventListener("scroll", (e) => handleNavigation(e));
}, []);
return (
<nav className={className}>
<p>
<i className="fas fa-pizza-slice"></i>Food finder
</p>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/about">About</Link>
</li>
</ul>
</nav>
);
};
export default Navbar;
Basically it's always detected as "down" because y in handleNavigation is always 0. If i check the state in DevTool the y state updates but in the handleNavigation doesn't.
Any suggestions what am I doing wrong?
Thanks for your help
TLDR;
Since this answer has drawn some attention, I've just developed an npm package based on it to allow everyone to use it as an independent package/library in their projects. Therefore if you want something to work with right away, you can add it to your project:
Demo
npm i #smakss/react-scroll-direction
or
yarn add #smakss/react-scroll-direction
Read more here.
The answer and its description
This is because you defined a useEffect() without any dependencies, so your useEffect() will only run once, and it never calls handleNavigation() on y changes. To fix this, you need to add y to your dependency array to tell your useEffect() to run whenever the y value changes. Then it would be best if you had another change to take effect in your code, where you are trying to initialize your y with window.scrollY, so you should do this in your useState() like:
const [y, setY] = useState(window.scrollY);
useEffect(() => {
window.addEventListener("scroll", (e) => handleNavigation(e));
return () => { // return a cleanup function to unregister our function since it's going to run multiple times
window.removeEventListener("scroll", (e) => handleNavigation(e));
};
}, [y]);
If, for some reason, window was unavailable there or you don't want to do it here, you can do it in two separate useEffect()s.
So your useEffect() should be like this:
useEffect(() => {
setY(window.scrollY);
}, []);
useEffect(() => {
window.addEventListener("scroll", (e) => handleNavigation(e));
return () => { // return a cleanup function to unregister our function since its gonna run multiple times
window.removeEventListener("scroll", (e) => handleNavigation(e));
};
}, [y]);
UPDATE (Working Solutions)
After implementing this solution on my own. I found out some notes should be applied to this solution. So since the handleNavigation() will change y value directly, we can ignore the y as our dependency and then add handleNavigation() as a dependency to our useEffect(), then due to this change we should optimize handleNavigation(), so we should use useCallback() for it. Then the final result will be something like this:
const [y, setY] = useState(window.scrollY);
const handleNavigation = useCallback(
e => {
const window = e.currentTarget;
if (y > window.scrollY) {
console.log("scrolling up");
} else if (y < window.scrollY) {
console.log("scrolling down");
}
setY(window.scrollY);
}, [y]
);
useEffect(() => {
setY(window.scrollY);
window.addEventListener("scroll", handleNavigation);
return () => {
window.removeEventListener("scroll", handleNavigation);
};
}, [handleNavigation]);
After a comment from #RezaSam, I noticed a teeny tiny mistake in the memoized version. Where I call handleNavigation within another arrow function, I found out (via the browser dev tool, event listeners tab) that each component rerender will register a new event to the window, so it might ruin the whole thing.
Working demo:
Final Optimized Solution
After all, I ended up that memoization, in this case, will help us to register a single event, to recognize scroll direction, but it is not fully optimized in printing the consoles because we are consoling inside the handleNavigation function and there is no other way around to print the desired consoles in the current implementation.
So, I realized there is a better way of storing the last page scroll position each time we want to check a new status. Also, to get rid of a vast amount of consoling scrolling up and scrolling down, we should define a threshold (Use debounce approach) to trigger the scroll event change. So I just searched through the web a bit and ended up with this gist, which was very useful. Then with the inspiration of it, I implement a simpler version.
This is how it looks:
const [scrollDir, setScrollDir] = useState("scrolling down");
useEffect(() => {
const threshold = 0;
let lastScrollY = window.pageYOffset;
let ticking = false;
const updateScrollDir = () => {
const scrollY = window.pageYOffset;
if (Math.abs(scrollY - lastScrollY) < threshold) {
ticking = false;
return;
}
setScrollDir(scrollY > lastScrollY ? "scrolling down" : "scrolling up");
lastScrollY = scrollY > 0 ? scrollY : 0;
ticking = false;
};
const onScroll = () => {
if (!ticking) {
window.requestAnimationFrame(updateScrollDir);
ticking = true;
}
};
window.addEventListener("scroll", onScroll);
console.log(scrollDir);
return () => window.removeEventListener("scroll", onScroll);
}, [scrollDir]);
How it works?
I will go from top to bottom and explain each code block.
So I just defined a threshold point with the initial value of 0; then, whenever the scroll goes up or down, it will make a new calculation. You can increase it if you don't want to immediately calculate a new page offset.
Then, instead of scrollY, I decide to use pageYOffset, which is more reliable in cross-browsing.
In the updateScrollDir function, we will check if the threshold is met; then, if it is completed, I will specify the scroll direction based on the current and previous page offset.
The most crucial part of it is the onScroll function. I just used requestAnimationFrame to make sure that we are calculating the new offset after the page got rendered wholly after scrolling. And then, with the ticking flag, we will ensure we are just running our event listener callback once in each requestAnimationFrame.
At last, we defined our listener and our cleanup function.
Then, the scrollDir state will contain the actual scroll direction.
Working demo:
Just wanted to come in with a neat solution, it's quite similar to habbahans but looks a little neater in my opinion.
let oldScrollY = 0;
const [direction, setDirection] = useState('up');
const controlDirection = () => {
if(window.scrollY > oldScrollY) {
setDirection('down');
} else {
setDirection('up');
}
oldScrollY = window.scrollY;
}
useEffect(() => {
window.addEventListener('scroll', controlDirection);
return () => {
window.removeEventListener('scroll', controlDirection);
};
},[]);
Here you can just access the hidden state to do what you wish with in your code.
Try this package - react-use-scroll-direction
import { useScrollDirection } from 'react-use-scroll-direction'
export const MyComponent = () => {
const { isScrollingDown } = useScrollDirection()
return (
<div>
{isScrollingDown ? 'Scrolling down' : 'scrolling up'}
</div>
)
}
Most of the answers seems a bit over-engineered in my opinion.
Here's what I use in my nextjs projects:
function useVerticalScrollDirection() {
const [direction, setDirection] = useState('up');
let prevScrollY = 0;
useEffect(() => {
// Using lodash, we set a throttle to the scroll event
// making it not fire more than once every 500 ms.
window.onscroll = throttle(() => {
// This value keeps the latest scrollY position
const { scrollY } = window;
// Checks if previous scrollY is less than latest scrollY
// If true, we are scrolling downwards, else scrollig upwards
const direction = prevScrollY < scrollY ? 'down' : 'up';
// Updates the previous scroll variable AFTER the direction is set.
// The order of events is key to making this work, as assigning
// the previous scroll before checking the direction will result
// in the direction always being 'up'.
prevScrollY = scrollY;
// Set the state to trigger re-rendering
setDirection(direction);
}, 500);
return () => {
// Remove scroll event on unmount
window.onscroll = null;
};
}, []);
return direction;
}
Then I use it my component like this:
function MyComponent() {
const verticalScrollDirection = useVerticalScrollDirection();
{....}
}
I was looking around and couldn't find a simple solution, so I looked into the event itself and there exists a "deltaY" which makes everything way simpler (no need to keep state of the last scroll value). The "deltaY" value shows the change in "y" that the event had (a positive deltaY means it was a scroll down event, and a negative deltaY means it was a scroll up event).
Here's how it works:
componentDidMount() {
window.addEventListener('scroll', e => this.handleNavigation(e));
}
handleNavigation = (e) => {
if (e.deltaY > 0) {
console.log("scrolling down");
} else if (e.deltaY < 0) {
console.log("scrolling up");
}
};
I found this neat & simple solution just few lines of codes
<div onWheel={ event => {
if (event.nativeEvent.wheelDelta > 0) {
console.log('scroll up');
} else {
console.log('scroll down');
}
}}
>
scroll on me!
</div>
onWheel synthetic event returns an event object having an attribute named nativeEvent containing the original event information. wheelDelta is used to detect the direction even if there is no effective scroll (overflow:hidden).
This is original source -> http://blog.jonathanargentiero.com/detect-scroll-direction-on-react/
Here's my React hook solution, useScrollDirection:
import { useEffect, useState } from 'react'
export type ScrollDirection = '' | 'up' | 'down'
type HistoryItem = { y: number; t: number }
const historyLength = 32 // Ticks to keep in history.
const historyMaxAge = 512 // History data time-to-live (ms).
const thresholdPixels = 64 // Ignore moves smaller than this.
let lastEvent: Event
let frameRequested: Boolean = false
let history: HistoryItem[] = Array(historyLength)
let pivot: HistoryItem = { t: 0, y: 0 }
export function useScrollDirection({
scrollingElement,
}: { scrollingElement?: HTMLElement | null } = {}): ScrollDirection {
const [scrollDirection, setScrollDirection] = useState<ScrollDirection>('')
useEffect(() => {
const element: Element | null =
scrollingElement !== undefined ? scrollingElement : document.scrollingElement
if (!element) return
const tick = () => {
if (!lastEvent) return
frameRequested = false
let y = element.scrollTop
const t = lastEvent.timeStamp
const furthest = scrollDirection === 'down' ? Math.max : Math.min
// Apply bounds to handle rubber banding
const yMax = element.scrollHeight - element.clientHeight
y = Math.max(0, y)
y = Math.min(yMax, y)
// Update history
history.unshift({ t, y })
history.pop()
// Are we continuing in the same direction?
if (y === furthest(pivot.y, y)) {
// Update "high-water mark" for current direction
pivot = { t, y }
return
}
// else we have backed off high-water mark
// Apply max age to find current reference point
const cutoffTime = t - historyMaxAge
if (cutoffTime > pivot.t) {
pivot.y = y
history.filter(Boolean).forEach(({ y, t }) => {
if (t > cutoffTime) pivot.y = furthest(pivot.y, y)
})
}
// Have we exceeded threshold?
if (Math.abs(y - pivot.y) > thresholdPixels) {
pivot = { t, y }
setScrollDirection(scrollDirection === 'down' ? 'up' : 'down')
}
}
const onScroll = (event: Event) => {
lastEvent = event
if (!frameRequested) {
requestAnimationFrame(tick)
frameRequested = true
}
}
element.addEventListener('scroll', onScroll)
return () => element.removeEventListener('scroll', onScroll)
}, [scrollDirection, scrollingElement])
return scrollDirection
}
Usage:
const [scrollingElement, setScrollingElement] = useState<HTMLElement | null>(null)
const ref = useCallback(node => setScrollingElement(node), [setScrollingElement])
const scrollDirection = useScrollDirection({ scrollingElement })
<ScrollingContainer {...{ ref }}>
<Header {...{ scrollDirection }}>
</ScrollingContainer>
Based on https://github.com/pwfisher/scroll-intent and https://github.com/dollarshaveclub/scrolldir. Also ported to React here: https://github.com/AnakinYuen/scroll-direction.
Here is my solution that extends some of the ideas found here. It fires only once every direction change and adds some params to fine-tune the hook call
const useScrollDirection = ({
ref,
threshold,
debounce,
scrollHeightThreshold,
}) => {
threshold = threshold || 10;
debounce = debounce || 10;
scrollHeightThreshold = scrollHeightThreshold || 0;
const [scrollDir, setScrollDir] = useState(null);
const debouncedSetScrollDir = _.debounce(setScrollDir, debounce);
useEffect(() => {
let lastScrollY = ref?.current?.scrollTop;
let lastScrollDir;
let ticking = false;
const hasScrollHeightThreshold =
ref?.current?.scrollHeight - ref?.current?.clientHeight >
scrollHeightThreshold;
const updateScrollDir = () => {
const scrollY = ref?.current?.scrollTop;
if (
Math.abs(scrollY - lastScrollY) < threshold ||
!hasScrollHeightThreshold
) {
ticking = false;
return;
}
const newScroll = scrollY > lastScrollY ? 'down' : 'up';
if (newScroll !== lastScrollDir) {
debouncedSetScrollDir(newScroll);
}
lastScrollY = scrollY > 0 ? scrollY : 0;
lastScrollDir = newScroll;
ticking = false;
};
const onScroll = () => {
if (!ticking) {
window.requestAnimationFrame(updateScrollDir);
ticking = true;
}
};
ref?.current?.addEventListener('scroll', onScroll);
return () => window.removeEventListener('scroll', onScroll);
}, []);
return scrollDir;
};
Codepen demo
I have been searches this things for an hours. But no one solution work for me, so i write like this and worked for my next.js project.
const [currentScroll, setCurrentScroll] = useState(0)
const [lastScroll, setLastScroll] = useState(0)
const [scrollUp, setScrollUp] = useState(false)
useEffect(()=>{
function handleScroll(){
setCurrentScroll(scrollY)
// check if current scroll
// more than last scroll
if(currentScroll>lastScroll){
setScrollUp('Down')
} else {
setScrollUp('Up')
}
}
// change the last scroll
setLastScroll(scrollY)
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll)
}
// this needed to change last scroll
// if currentscroll has change
},[currentScroll]) }
useEffect(() => {
setY(window.scrollY);
window.addEventListener("scroll", (e) => handleNavigation(e));
}, []);
Because you pass an empty array into useEffect, so this hook only runs once and does not render every time y changes.
To change the value of y, we need to re-render when scrolling, so we need to pass a state into this array. In here, we see that window.scrollY always changes once you scroll, so [window.scrollY] can be the best solution to resolve your problem.
useEffect(() => {
setY(window.scrollY);
window.addEventListener("scroll", (e) => handleNavigation(e));
}, [window.scrollY]);
CopePen Demo
Your problem relates to using the dependency in useEffect, you can reference it in this link from the React documentation (note part): useEffect Dependency

Is an observable just a partially applied function?

For clarity, this question applies to the observable pattern implemented in javascript (the code I'll write here is all ES6).
Recently I've been trying to figure out how observables actually work. I found a couple articles/posts that describe them within the context of creating a class that takes a function as its constructor argument. This function just waits around for an observer, and the method .subscribe(observer) finally triggers this function with the observer as argument.
Here is my attempt at creating an observable with class syntax that implements an interval with a limit on the number of interval ticks (so that there is something to complete with):
class Observable {
constructor(pushFn) {
this.pushFn = pushFn; // waits for observer as argument
}
static interval(milliSecs, tickLimit) {
return new Observable(observer => {
if (milliSecs <= 0) {
observer.error("Invalid interval. Interval value must be larger than 0.")
} else {
let tick = 0;
const inter = setInterval(() => {
observer.next(tick += 1);
if (tick === tickLimit) {
clearInterval(inter);
observer.complete(tickLimit);
}
}, milliSecs);
}
});
}
subscribe(observer) {
return this.pushFn(observer);
}
}
What I find kind of surprising (given that I always thought of observables as some sort mysterious super complex thing) is that this functionality can be replicated with the following function:
const observableIntervalOf = (milliSecs, tickLimit) => observer => {
if (milliSecs <= 0) {
observer.error("Invalid interval. Interval value must be larger than 0.")
} else {
let tick = 0;
const inter = setInterval(() => {
observer.next(tick += 1);
if (tick === tickLimit) {
clearInterval(inter);
observer.complete(tickLimit);
}
}, milliSecs);
}
};
Then, the effect of each of the following is identical:
Observable.interval(1000, 5).subscribe(someObserver);
const fiveSeconds = observableIntervalOf(1000, 5);
fiveSeconds(someObserver);
Am I right in assuming that its best to think of observables as partially applied functions? Are there instances where this is not the case?

MutationObserver for only the last mutation

I'm using the MutationObserver to save position changes in a draggable object.
It looks like this:
let observer = new MutationObserver( (mutations) => {
mutations.forEach( (mutation) => {
this.builderData[element.id].$position.left = element.style.left;
this.builderData[element.id].$position.top = element.style.top;
this.saveBuilderData();
});
});
observer.observe(element, { attributes : true, attributeFilter : ['style'] });
However, this run for every pixel changed, so it is a lot saving operations being ran. I would like to only save after it has stop mutating for about 1 second, or that each callback excludes the previous one. I already did something like this with RxJava, but did not worked with MutationObserver.
Any ideas?
You could add a simple 1 second delay via setTimeout.
This way previous callbacks are discarded and the style is only changed after 1 second of inactivity:
let timer;
let observer = new MutationObserver( (mutations) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
mutations.forEach( (mutation) => {
this.builderData[element.id].$position.left = element.style.left;
this.builderData[element.id].$position.top = element.style.top;
this.saveBuilderData();
});
}, 1000);
});
observer.observe(element, { attributes : true, attributeFilter : ['style'] });

Categories

Resources