I am making a blog website and I would like to have one sticky element that would be updated which each new year and month as the user scrolls. So that the header would show the current month and year of the listed blog articles.
When coding, I try to achieve an effect with HTML, then CSS if it doesn't work, then JS if it still doesn't. I believe this is good practice as it uses built in features and reduces the needed computing resources but let me know if you do not share this opinion.
Ideally, the style of the elements would be changed when 'stuck'. For this, I took a look at David Walsh's solution that uses IntersectionObserver
but it glitches when adding more than one element.
The main issue I face is that when having several entries, the script detects an element as 'pinned' when it is at the bottom border of the window.
Here is a snippet. I also have made a jsfiddle with the same code.
//Essentially putting David Walsh's code in a loop
document.querySelectorAll(".myElement").forEach((i) => {
const observer = new IntersectionObserver(([i]) => i.target.classList.toggle("is-pinned", i.intersectionRatio < 1),
{threshold: [1]});
observer.observe(i);
})
#parent {
height: 2000px;
}
.myElement {
position: sticky;
top: -1px;
}
/* styles for when the header is in sticky mode. The transition times accentuate the undesired effect */
.myElement.is-pinned {
color: red;
transition: color 0.3s, background-color 0.3s;
background-color: orange;
}
<div id="parent">
<!-- Adding more than one 'hello' element. The br's are here to add vertical space and be longer than the viewport height -->
<br><br><br><br>
<div class="myElement">Hello!</div>
<br><br><br><br>
<div class="myElement">Hello 2!</div>
<br><br><br><br>
<div class="myElement">Hello 3!</div>
<br><br><br><br>
<div class="myElement">Hello 4!</div>
<br><br><br><br>
<div class="myElement">Hello 5!</div>
<br><br><br><br>
<div class="myElement">Hello 6!</div>
<br><br><br><br>
<div class="myElement">Hello 7!</div>
<br><br><br><br>
<div class="myElement">Hello 8!</div>
</div>
First of all, you only need one IntersectionObserver
. As long as you need the same callback and options (which you do in this case), you can observe()
multiple elements with the same observer. Only your observer.observe(i);
needs to be inside the loop.
But your single observer can then get called with multiple entries at once if you jump up or down the page. So you'll need to loop over all the observed entries.
More importantly, intersectionRatio
doesn't care where on the screen the element is. Elements cross the 100% visibility threshold both at the top and bottom of the box.
You only care about the elements at the top of the box. The IntersectionObserverEntry
object also has a boundingClientRect
property which tells you where the element is now. You can use that instead to only toggle elements at the top.
So you end up with this:
const observer = new IntersectionObserver((entries) => {
for (let i of entries) {
i.target.classList.toggle(
"is-pinned", i.boundingClientRect.y < 0);
}
}, {threshold: [1]});
document.querySelectorAll(".myElement").forEach(i => observer.observe(i));
This still leaves you with a problem, however. In your example the box you're scrolling is long enough that if you jump straight from the top to the bottom there are elements that go from "0% visible below the box" to "99% visible at the top of the box". This doesn't cross the 100% threshold, so the IntersectionObserver callback never fires for those elements! That means they don't get the is-pinned
class.
You can simply add another threshold of 0% to the same observer to catch those changes too:
const observer = new IntersectionObserver((entries) => {
for (let i of entries) {
i.target.classList.toggle(
"is-pinned", i.boundingClientRect.y < 0);
}
}, {threshold: [0, 1]});
document.querySelectorAll(".myElement").forEach(i => observer.observe(i));
Now both elements that go from visible to sticky (or vice versa) and elements that go from invisible to sticky (or vice versa) get their class toggled.