javascriptsticky

How to determine if an element is CSS sticky to the top OR bottom?


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>


Solution

  • 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>