I need to know out when dozens of HTMLElements are inside or outside of the viewport when scrolling down the page. So I'm using the IntersectionObserver API to create several instances of a VisibilityHelper class, each one with its own IntersectionObserver. With this helper class, I can detect when any HTMLElement is 50% visible or hidden:
Working demo:
// Create helper class
class VisibilityHelper {
constructor(htmlElem, hiddenCallback, visibleCallback) {
this.observer = new IntersectionObserver((entities) => {
const ratio = entities[0].intersectionRatio;
if (ratio <= 0.0) {
hiddenCallback();
} else if (ratio >= 0.5) {
visibleCallback();
}
}, {threshold: [0.0, 0.5]});
this.observer.observe(htmlElem);
}
}
// Get elements
const headerElem = document.getElementById("header");
const footerElem = document.getElementById("footer");
// Use helper class to know whether visible or hidden
const headerViz = new VisibilityHelper(
headerElem,
() => {console.log('header is hidden')},
() => {console.log('header is visible')},
);
const footerViz = new VisibilityHelper(
footerElem,
() => {console.log('footer is hidden')},
() => {console.log('footer is visible')},
);
#page {
width: 100%;
height: 1500px;
position: relative;
background: linear-gradient(#000, #fff);
}
#header {
position: absolute;
top: 0;
width: 100%;
height: 100px;
background: #f90;
text-align: center;
}
#footer {
position: absolute;
bottom: 0;
width: 100%;
height: 100px;
background: #09f;
text-align: center;
}
<div id="page">
<div id="header">
Header
</div>
<div id="footer">
Footer
</div>
</div>
The problem is that my demo above creates one IntersectionObserver for each HTMLElement that needs to be watched. I need to use this on 100 elements, and this question indicates that we should only use one IntersectionObserver per page for performance reasons. Secondly, the API also suggests that one observer can be used to watch several elements, since the callback will give you a list of entries.
How would you use a single IntersectionObserver to watch multiple htmlElements and trigger unique hidden/visible callbacks for each element?
You can define a callback mapping between your target elements and their visibility state. Then inside of your IntersectionObserver callback, use the IntersectionObserverEntry.target to read the id and invoke the associated callback from the map based on the visibility state of visible or hidden.
Here is a simplified approach based on your example. The gist of the approach is defining the callbacks map and reading the target from the IntersectionObserverEntry:
// Create helper class
class VisibilityHelper {
constructor(htmlElems, callbacks) {
this.callbacks = callbacks;
this.observer = new IntersectionObserver(
(entities) => {
const ratio = entities[0].intersectionRatio;
const target = entities[0].target;
if (ratio <= 0.0) {
this.callbacks[target.id].hidden();
} else if (ratio >= 0.5) {
this.callbacks[target.id].visible();
}
},
{ threshold: [0.0, 0.5] }
);
htmlElems.forEach((elem) => this.observer.observe(elem));
}
}
// Get elements
const headerElem = document.getElementById("header");
const footerElem = document.getElementById("footer");
// Use helper class to know whether visible or hidden
const helper = new VisibilityHelper([headerElem, footerElem], {
header: {
visible: () => console.log("header is visible"),
hidden: () => console.log("header is hidden"),
},
footer: {
visible: () => console.log("footer is visible"),
hidden: () => console.log("footer is hidden"),
},
});
#page {
width: 100%;
height: 1500px;
position: relative;
background: linear-gradient(#000, #fff);
}
#header {
position: absolute;
top: 0;
width: 100%;
height: 100px;
background: #f90;
text-align: center;
}
#footer {
position: absolute;
bottom: 0;
width: 100%;
height: 100px;
background: #09f;
text-align: center;
}
<div id="page">
<div id="header">
Header
</div>
<div id="footer">
Footer
</div>
</div>
You can use a similar approach if you want to loop over the entire array of entities instead of just considering the first entities[0].
I'm trying to change the z-index on the clicked item on vanilla JS.
When a click event happens, the clicked item should come front. There are only two divs atm, but there will be more.
I'm storing the last clicked item, so that if new click event happens the last clicked item have less z-index. But it doesn't seem to work.(both of items are position relative)
Any help would be appreciated.
<div class="post" data-name="post" draggable="true">1</div>
<div class="post" data-name="post" draggable="true">2</div>
const a = new A()
memoSection.addEventListener('click', ({target})=>{
switch(target.dataset.name){
case "post":
let clickedItem ="";
a.bringFront(clickedItem)
break;
})
class A{
constructor(){
this.selected = null
}
bringFront(clickedItem){
this.selected = clickedItem; //store the previously clicked element
if(!clickedItem.style.zIndex == 10 || !clickedItem.style.zIndex){
this.selected.zIndex = 0
clickedItem.style.zIndex = 10
} else {
this.selected.zIndex = 10
clickedItem.style.zIndex = 0 }
}
}
I don't understand what your code was trying to do, but here is example how to get desired effect:
document.querySelectorAll('.post').forEach(item => {
//get all elements with class .post
item.addEventListener('click', event => {
// add Event Listener to each
document.querySelectorAll('.post').forEach(el => {
el.style.zIndex = "0";
})
// when clicked fetch all again and set all back to 0
item.style.zIndex = "10";
//set clicked only to 10
console.clear()
console.log(item.innerHTML);
})
})
.post:first-of-type {
position: absolute;
background-color: blue;
left: 100px;
top: 50px;
width: 150px;
height: 150px;
z-index: 0;
}
.post {
position: absolute;
background-color: red;
left: 25px;
top: 50px;
width: 150px;
height: 150px;
z-index: 0;
}
<div class="post" data-name="post" draggable="true">1</div>
<div class="post" data-name="post" draggable="true">2</div>
I am building a carousel, very minimalist, using CSS snap points. It is important for me to have CSS only options, but I'm fine with enhancing a bit with javascript (no framework).
I am trying to add previous and next buttons to scroll programmatically to the next or previous element. If javascript is disabled, buttons will be hidden and carousel still functionnal.
My issue is about how to trigger the scroll to the next snap point ?
All items have different size, and most solution I found require pixel value (like scrollBy used in the exemple). A scrollBy 40px works for page 2, but not for others since they are too big (size based on viewport).
function goPrecious() {
document.getElementById('container').scrollBy({
top: -40,
behavior: 'smooth'
});
}
function goNext() {
document.getElementById('container').scrollBy({
top: 40,
behavior: 'smooth'
});
}
#container {
scroll-snap-type: y mandatory;
overflow-y: scroll;
border: 2px solid var(--gs0);
border-radius: 8px;
height: 60vh;
}
#container div {
scroll-snap-align: start;
display: flex;
justify-content: center;
align-items: center;
font-size: 4rem;
}
#container div:nth-child(1) {
background: hotpink;
color: white;
height: 50vh;
}
#container div:nth-child(2) {
background: azure;
height: 40vh;
}
#container div:nth-child(3) {
background: blanchedalmond;
height: 60vh;
}
#container div:nth-child(4) {
background: lightcoral;
color: white;
height: 40vh;
}
<div id="container">
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
</div>
<button onClick="goPrecious()">previous</button>
<button onClick="goNext()">next</button>
Nice question! I took this as a challenge.
So, I increased JavaScript for it to work dynamically. Follow my detailed solution (in the end the complete code):
First, add position: relative to the .container, because it need to be reference for scroll and height checkings inside .container.
Then, let's create 3 global auxiliary variables:
1) One to get items scroll positions (top and bottom) as arrays into an array. Example: [[0, 125], [125, 280], [280, 360]] (3 items in this case).
3) One that stores half of .container height (it will be useful later).
2) Another one to store the item index for scroll position
var carouselPositions;
var halfContainer;
var currentItem;
Now, a function called getCarouselPositions that creates the array with items positions (stored in carouselPositions) and calculates the half of .container (stored in halfContainer):
function getCarouselPositions() {
carouselPositions = [];
document.querySelectorAll('#container div').forEach(function(div) {
carouselPositions.push([div.offsetTop, div.offsetTop + div.offsetHeight]); // add to array the positions information
})
halfContainer = document.querySelector('#container').offsetHeight/2;
}
getCarouselPositions(); // call it once
Let's replace the functions on buttons. Now, when you click on them, the same function will be called, but with "next" or "previous" argument:
<button onClick="goCarousel('previous')">previous</button>
<button onClick="goCarousel('next')">next</button>
Here is about the goCarousel function itself:
First, it creates 2 variables that store top scroll position and bottom scroll position of carousel.
Then, there are 2 conditionals to see if the current carousel position is on most top or most bottom.
If it's on top and clicked "next" button, it will go to the second item position. If it's on bottom and clicked "previous" button, it will go the previous one before the last item.
If both conditionals failed, it means the current item is not the first or the last one. So, it checks to see what is the current position, calculating using the half of the container in a loop with the array of positions to see what item is showing. Then, it combines with "previous" or "next" checking to set the correct next position for currentItem variable.
Finally, it goes to the correct position through scrollTo using currentItem new value.
Below, the complete code:
var carouselPositions;
var halfContainer;
var currentItem;
function getCarouselPositions() {
carouselPositions = [];
document.querySelectorAll('#container div').forEach(function(div) {
carouselPositions.push([div.offsetTop, div.offsetTop + div.offsetHeight]); // add to array the positions information
})
halfContainer = document.querySelector('#container').offsetHeight/2;
}
getCarouselPositions(); // call it once
function goCarousel(direction) {
var currentScrollTop = document.querySelector('#container').scrollTop;
var currentScrollBottom = currentScrollTop + document.querySelector('#container').offsetHeight;
if (currentScrollTop === 0 && direction === 'next') {
currentItem = 1;
} else if (currentScrollBottom === document.querySelector('#container').scrollHeight && direction === 'previous') {
console.log('here')
currentItem = carouselPositions.length - 2;
} else {
var currentMiddlePosition = currentScrollTop + halfContainer;
for (var i = 0; i < carouselPositions.length; i++) {
if (currentMiddlePosition > carouselPositions[i][0] && currentMiddlePosition < carouselPositions[i][1]) {
currentItem = i;
if (direction === 'next') {
currentItem++;
} else if (direction === 'previous') {
currentItem--
}
}
}
}
document.getElementById('container').scrollTo({
top: carouselPositions[currentItem][0],
behavior: 'smooth'
});
}
window.addEventListener('resize', getCarouselPositions);
#container {
scroll-snap-type: y mandatory;
overflow-y: scroll;
border: 2px solid var(--gs0);
border-radius: 8px;
height: 60vh;
position: relative;
}
#container div {
scroll-snap-align: start;
display: flex;
justify-content: center;
align-items: center;
font-size: 4rem;
}
#container div:nth-child(1) {
background: hotpink;
color: white;
height: 50vh;
}
#container div:nth-child(2) {
background: azure;
height: 40vh;
}
#container div:nth-child(3) {
background: blanchedalmond;
height: 60vh;
}
#container div:nth-child(4) {
background: lightcoral;
color: white;
height: 40vh;
}
<div id="container">
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
</div>
<button onClick="goCarousel('previous')">previous</button>
<button onClick="goCarousel('next')">next</button>
Another good detail to add is to call getCarouselPositions function again if the window resizes:
window.addEventListener('resize', getCarouselPositions);
That's it.
That was cool to do. I hope it can help somehow.
I've just done something similar recently. The idea is to use IntersectionObserver to keep track of which item is in view currently and then hook up the previous/next buttons to event handler calling Element.scrollIntoView().
Anyway, Safari does not currently support scroll behavior options. So you might want to polyfill it on demand with polyfill.app service.
let activeIndex = 0;
const container = document.querySelector("#container");
const elements = [...document.querySelectorAll("#container div")];
function handleIntersect(entries){
const entry = entries.find(e => e.isIntersecting);
if (entry) {
const index = elements.findIndex(
e => e === entry.target
);
activeIndex = index;
}
}
const observer = new IntersectionObserver(handleIntersect, {
root: container,
rootMargin: "0px",
threshold: 0.75
});
elements.forEach(el => {
observer.observe(el);
});
function goPrevious() {
if(activeIndex > 0) {
elements[activeIndex - 1].scrollIntoView({
behavior: 'smooth'
})
}
}
function goNext() {
if(activeIndex < elements.length - 1) {
elements[activeIndex + 1].scrollIntoView({
behavior: 'smooth'
})
}
}
#container {
scroll-snap-type: y mandatory;
overflow-y: scroll;
border: 2px solid var(--gs0);
border-radius: 8px;
height: 60vh;
}
#container div {
scroll-snap-align: start;
display: flex;
justify-content: center;
align-items: center;
font-size: 4rem;
}
#container div:nth-child(1) {
background: hotpink;
color: white;
height: 50vh;
}
#container div:nth-child(2) {
background: azure;
height: 40vh;
}
#container div:nth-child(3) {
background: blanchedalmond;
height: 60vh;
}
#container div:nth-child(4) {
background: lightcoral;
color: white;
height: 40vh;
}
<div id="container">
<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>
</div>
<button onClick="goPrevious()">previous</button>
<button onClick="goNext()">next</button>
An easier approach done with react.
export const AppCarousel = props => {
const containerRef = useRef(null);
const carouselRef = useRef(null);
const [state, setState] = useState({
scroller: null,
itemWidth: 0,
isPrevHidden: true,
isNextHidden: false
})
const next = () => {
state.scroller.scrollBy({left: state.itemWidth * 3, top: 0, behavior: 'smooth'});
// Hide if is the last item
setState({...state, isNextHidden: true, isPrevHidden: false});
}
const prev = () => {
state.scroller.scrollBy({left: -state.itemWidth * 3, top: 0, behavior: 'smooth'});
setState({...state, isNextHidden: false, isPrevHidden: true});
// Hide if is the last item
// Show remaining
}
useEffect(() => {
const items = containerRef.current.childNodes;
const scroller = containerRef.current;
const itemWidth = containerRef.current.firstElementChild?.clientWidth;
setState({...state, scroller, itemWidth});
return () => {
}
},[props.items])
return (<div className="app-carousel" ref={carouselRef}>
<div className="carousel-items shop-products products-swiper" ref={containerRef}>
{props.children}
</div>
<div className="app-carousel--navigation">
<button className="btn prev" onClick={e => prev()} hidden={state.isPrevHidden}><</button>
<button className="btn next" onClick={e => next()} hidden={state.isNextHidden}>></button>
</div>
</div>)
}
I was struggling with the too while working with a react project and came up with this solution. Here's a super basic example of the code using react and styled-components.
import React, { useState, useRef } from 'react';
import styled from 'styled-components';
const App = () => {
const ref = useRef();
const [scrollX, setScrollX] = useState(0);
const scrollSideways = (px) => {
ref.current.scrollTo({
top: 0,
left: scrollX + px,
behavior: 'smooth'
});
setScrollX(scrollX + px);
};
return (
<div>
<List ref={ref}>
<ListItem color="red">Card 1</ListItem>
<ListItem color="blue">Card 2</ListItem>
<ListItem color="green">Card 3</ListItem>
<ListItem color="yellow">Card 4</ListItem>
</List>
<button onClick={() => scrollSideways(-600)}> Left </button>
<button onClick={() => scrollSideways(600)}> Right </button>
</div>
);
};
const List = styled.ul`
display: flex;
overflow-x: auto;
padding-inline-start: 40px;
scroll-snap-type: x mandatory;
list-style: none;
padding: 40px;
width: 700px;
`;
const ListItem = styled.li`
display: flex;
flex-shrink: 0;
scroll-snap-align: start;
background: ${(p) => p.color};
width: 600px;
margin-left: 15px;
height: 200px;
`;
I want to show different data or hide component if it doesn't fit on screen.
I made a working example but i don't think it's a right/elegant way to do it.
Maybe someone can show me a better way?
This image of example that i made in codepen.
1. I want to hide first red block if it's doesn't fit in grey.
I don't want to do it on media queries of window-size because my red blocks maybe be different in size from user to user.
Codepen example (resize to hide block): https://codepen.io/bofemptiness/pen/YJRKGj
const styled = styled.default
const Component = React.Component;
const DivHat = styled.div`
position: fixed;
top: 0;
width: 100% ;
height: 50px;
display: flex;
justify-content: center;
align-items: center;
background-color: blue;
`
const DivProfile = styled.div`
display: flex;
height: 100%;
cursor: pointer;
`
const ExpCooDiv = styled.div`
display: flex;
flex-direction: column;
font-size: 1rem;
`
class App extends Component {
constructor(props) {
super(props)
this.state = { showCookies: true, lvlRefLastSize: 0 }
this.statsRef = React.createRef()
this.cocRef = React.createRef()
this.lvlRef = React.createRef()
this.mediaStats = this.mediaStats.bind(this);
}
componentDidMount() {
// Add listner when window resizes
window.addEventListener('resize', this.mediaStats)
// Activate function at least one time on load
this.mediaStats()
}
componentWillUnmount() {
window.removeEventListener('resize', this.mediaStats)
}
// Show/hide first red block if summ of red blocks widths <= width of grey one
mediaStats = () => {
console.log(this.statsRef.current.scrollWidth)
if (this.lvlRef.current.scrollWidth != 0)
this.setState({ lvlRefLastSize: this.lvlRef.current.scrollWidth })
if (this.statsRef.current.scrollWidth <= this.state.lvlRefLastSize + this.cocRef.current.scrollWidth) {
this.setState({ showCookies: false })
} else {
this.setState({ showCookies: true })
}
}
render () {
return(
<DivHat>
<div>Menu</div>
<div id='test' ref={this.statsRef}>
<div ref={this.lvlRef} id='test2'>
{this.state.showCookies &&
<React.Fragment>
<span>DATA that i hide</span>
</React.Fragment>
}
</div>
<div ref={this.cocRef} id='test2'>
ANOTHER DATA
</div>
</div>
<DivProfile >
<div> Profile </div>
</DivProfile>
</DivHat>
)
}
}
ReactDOM.render(<App />, document.getElementById('app'))
.container {
white-space: nowrap;
overflow: hidden;
}
.inner {
width: 100%;
}
.list-item {
display: 'inline-block',
boxSizing: 'border-box',
border: '3px solid black'
}
import React, { Component } from 'react'
function calcItemWidth(width, tiles) {
return width / tiles
}
export class Container extends Component {
constructor() {
super(...arguments);
}
get container() {
return document.querySelector('.container');
}
componentDidMount() {
const itemwidth = calcItemWidth(this.container.clientWidth, 2)
const listItems = document.querySelectorAll('.list-item');
Array.from(listItems)
.map(item => {
item.style.width = `400px`;
})
}
renderChildren() {
return this.props.children.map(
(child, index) => (
<li key={child.key} data-index={index} className="list-item" style={listItem}>
{child}
</li>
)
)
}
render() {
return (
<div style={container} className="container">
{this.renderChildren()}
</div>
)
}
}
export default Container
I have a component called container. Container has a fixed width of 800px and has overflow hidden. Inside container, I have inner, I want inner to be width of the elements it contains. So if I have 4 listItems, inner should be 1600px wide.
I tried set the width of inner to width: 100% and min-width: 100% etc, but it's not taking the width of its children. How to make inner expand beyond the width of the container?
EDIT: The overflow of inner doesn't have to be visible nor have a slider. It just has to be physically the total width of its children.
Add display: inline-block; to inner class