I need to apply a CSS class to a position:sticky
element when it has become stuck. I've got this working when I use a top position, but I'm having trouble figuring out how to determine this for a bottom position. I suppose I need to take the height into account somewhere, but I'm just not sure what the best thing would be here.
I also need this to work with offsets, just just a top or bottom position of 0
. Here's what I have so far
const stickyElements = [...document.querySelectorAll(".sticky")];
window.addEventListener("scroll", () => {
stickyElements.forEach((el) => toggleClassIfStuck(el))
});
window.dispatchEvent(new Event('scroll')); //trigger initially
function toggleClassIfStuck(el){
const computedStyles = getComputedStyle(el);
if (this.canBeStuck(computedStyles)) {
const hasTopPositionSet = computedStyles.top !== 'auto';
const hasBottomPositionSet = computedStyles.bottom !== 'auto';
if (hasTopPositionSet || hasBottomPositionSet) {
el.classList.toggle('is-stuck', this.isStuck(el, computedStyles, hasBottomPositionSet))
}
}
}
function canBeStuck(computedStyles) {
return computedStyles.display !== 'none' && computedStyles.position === 'sticky';
}
function isStuck(el, computedStyles, shouldUseBottomPosition) {
const offsetParent = el.offsetParent; //the element which this element is relatively sticky to
const rect = el.getBoundingClientRect();
const parentRect = offsetParent.getBoundingClientRect();
if (shouldUseBottomPosition) {
//this isn't correct, but not sure what to check here!
const elBottom = parseInt(computedStyles.bottom, 10);
return rect.top - rect.bottom === elBottom;
} else {
const elTop = parseInt(computedStyles.top,10);
return rect.top === elTop;
}
}
.sticky {
position:sticky;
background: #EEE;
padding: .5rem;
border: 1px solid #DDD;
transition: all 200ms;
}
.sticky-top { top:0; }
.sticky-top-offset { top: 1rem;}
.sticky-bottom { bottom: 0; }
.sticky-bottom-offset { bottom: 1rem; }
.is-stuck{
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.25);
background: lightskyblue;
}
main{ display: flex; gap:.5rem;}
section{ height:120vh; width: 40%; }
<main>
<section>
<br>
<div id="one" class="sticky sticky-top">Top</div>
<br><br><br><br><br><br><br><br><br><br><br><br>
<br><br><br><br><br><br><br><br><br><br><br><br>
<br><br><br><br><br><br><br><br><br><br><br><br>
<div class="sticky sticky-bottom">Bottom</div>
<br>
</section>
<section>
<br><br><br><br>
<div class="sticky sticky-top-offset">Top with offset</div>
<br><br><br><br><br><br><br><br><br><br><br><br>
<br><br><br><br><br><br><br><br><br><br><br><br>
<br><br><br><br><br><br><br><br><br><br><br><br>
<div class="sticky sticky-bottom-offset">Bottom with offset</div>
<br><br><br><br>
</section>
</main>
The top-sticky elements become stuck when its distance from the viewport's top edge reaches their top
value.
It's a similar concept for bottom-sticky elements; they're stuck when its distance from the viewport's bottom edge reaches their bottom
value.
You just need to find out that distance and you can do that by subtracting rect.bottom
from window.innerHeight
:
const elBottomViewportDistance = window.innerHeight - rect.bottom;
const stickyElements = [...document.querySelectorAll(".sticky")];
window.addEventListener("scroll", () => {
stickyElements.forEach((el) => toggleClassIfStuck(el))
});
window.dispatchEvent(new Event('scroll')); //trigger initially
function toggleClassIfStuck(el){
const computedStyles = getComputedStyle(el);
if (this.canBeStuck(computedStyles)) {
const hasTopPositionSet = computedStyles.top !== 'auto';
const hasBottomPositionSet = computedStyles.bottom !== 'auto';
if (hasTopPositionSet || hasBottomPositionSet) {
el.classList.toggle('is-stuck', this.isStuck(el, computedStyles, hasBottomPositionSet))
}
}
}
function canBeStuck(computedStyles) {
return computedStyles.display !== 'none' && computedStyles.position === 'sticky';
}
function isStuck(el, computedStyles, shouldUseBottomPosition) {
const offsetParent = el.offsetParent; //the element which this element is relatively sticky to
const rect = el.getBoundingClientRect();
const parentRect = offsetParent.getBoundingClientRect();
if (shouldUseBottomPosition) {
const elBottom = parseInt(computedStyles.bottom, 10);
return window.innerHeight - rect.bottom <= elBottom;
} else {
const elTop = parseInt(computedStyles.top,10);
return rect.top <= elTop;
}
}
.sticky {
position:sticky;
background: #EEE;
padding: .5rem;
border: 1px solid #DDD;
transition: all 200ms;
}
.sticky-top { top:0; }
.sticky-top-offset { top: 1rem;}
.sticky-bottom { bottom: 0; }
.sticky-bottom-offset { bottom: 1rem; }
.is-stuck{
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.25);
background: lightskyblue;
}
main{ display: flex; gap:.5rem;}
section{ height:120vh; width: 40%; }
<main>
<section>
<br>
<div id="one" class="sticky sticky-top">Top</div>
<br><br><br><br><br><br><br><br><br><br><br><br>
<br><br><br><br><br><br><br><br><br><br><br><br>
<br><br><br><br><br><br><br><br><br><br><br><br>
<div class="sticky sticky-bottom">Bottom</div>
<br>
</section>
<section>
<br><br><br><br>
<div class="sticky sticky-top-offset">Top with offset</div>
<br><br><br><br><br><br><br><br><br><br><br><br>
<br><br><br><br><br><br><br><br><br><br><br><br>
<br><br><br><br><br><br><br><br><br><br><br><br>
<div class="sticky sticky-bottom-offset">Bottom with offset</div>
<br><br><br><br>
</section>
</main>