Event to detect when position:sticky is triggered

Solution 1:

Demo with IntersectionObserver (use a trick):

// get the sticky element
const stickyElm = document.querySelector('header')

const observer = new IntersectionObserver( 
  ([e]) => e.target.classList.toggle('isSticky', e.intersectionRatio < 1),
  {threshold: [1]}
);

observer.observe(stickyElm)
body{ height: 200vh; font:20px Arial; }

section{
  background: lightblue;
  padding: 2em 1em;
}

header{
  position: sticky;
  top: -1px;                       /* ➜ the trick */

  padding: 1em;
  padding-top: calc(1em + 1px);    /* ➜ compensate for the trick */

  background: salmon;
  transition: .1s;
}

/* styles for when the header is in sticky mode */
header.isSticky{
  font-size: .8em;
  opacity: .5;
}
<section>Space</section>
<header>Sticky Header</header>

The top value needs to be -1px or the element will never intersect with the top of the browser window (thus never triggering the intersection observer).

To counter this 1px of hidden content, an additional 1px of space should be added to either the border or the padding of the sticky element.

💡 Alternatively, if you wish to keep the CSS as is (top:0), then you can apply the "correction" at the intersection observer-level by adding the setting rootMargin: '-1px 0px 0px 0px' (as @mattrick showed in his answer)

Demo with old-fashioned scroll event listener:

  1. auto-detecting first scrollable parent
  2. Throttling the scroll event
  3. Functional composition for concerns-separation
  4. Event callback caching: scrollCallback (to be able to unbind if needed)

// get the sticky element
const stickyElm = document.querySelector('header');

// get the first parent element which is scrollable
const stickyElmScrollableParent = getScrollParent(stickyElm);

// save the original offsetTop. when this changes, it means stickiness has begun.
stickyElm._originalOffsetTop = stickyElm.offsetTop;


// compare previous scrollTop to current one
const detectStickiness = (elm, cb) => () => cb & cb(elm.offsetTop != elm._originalOffsetTop)

// Act if sticky or not
const onSticky = isSticky => {
   console.clear()
   console.log(isSticky)
   
   stickyElm.classList.toggle('isSticky', isSticky)
}

// bind a scroll event listener on the scrollable parent (whatever it is)
// in this exmaple I am throttling the "scroll" event for performance reasons.
// I also use functional composition to diffrentiate between the detection function and
// the function which acts uppon the detected information (stickiness)

const scrollCallback = throttle(detectStickiness(stickyElm, onSticky), 100)
stickyElmScrollableParent.addEventListener('scroll', scrollCallback)



// OPTIONAL CODE BELOW ///////////////////

// find-first-scrollable-parent
// Credit: https://stackoverflow.com/a/42543908/104380
function getScrollParent(element, includeHidden) {
    var style = getComputedStyle(element),
        excludeStaticParent = style.position === "absolute",
        overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/;

    if (style.position !== "fixed") 
      for (var parent = element; (parent = parent.parentElement); ){
          style = getComputedStyle(parent);
          if (excludeStaticParent && style.position === "static") 
              continue;
          if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX)) 
            return parent;
      }

    return window
}

// Throttle
// Credit: https://jsfiddle.net/jonathansampson/m7G64
function throttle (callback, limit) {
    var wait = false;                  // Initially, we're not waiting
    return function () {               // We return a throttled function
        if (!wait) {                   // If we're not waiting
            callback.call();           // Execute users function
            wait = true;               // Prevent future invocations
            setTimeout(function () {   // After a period of time
                wait = false;          // And allow future invocations
            }, limit);
        }
    }
}
header{
  position: sticky;
  top: 0;

  /* not important styles */
  background: salmon;
  padding: 1em;
  transition: .1s;
}

header.isSticky{
  /* styles for when the header is in sticky mode */
  font-size: .8em;
  opacity: .5;
}

/* not important styles*/

body{ height: 200vh; font:20px Arial; }

section{
  background: lightblue;
  padding: 2em 1em;
}
<section>Space</section>
<header>Sticky Header</header>

Here's a React component demo which uses the first technique

Solution 2:

I found a solution somewhat similar to @vsync's answer, but it doesn't require the "hack" that you need to add to your stylesheets. You can simply change the boundaries of the IntersectionObserver to avoid needing to move the element itself outside of the viewport:

const observer = new IntersectionObserver(callback, {
  rootMargin: '-1px 0px 0px 0px',
  threshold: [1],
});

observer.observe(element);

Solution 3:

If anyone gets here via Google one of their own engineers has a solution using IntersectionObserver, custom events, and sentinels:

https://developers.google.com/web/updates/2017/09/sticky-headers

Solution 4:

Just use vanilla JS for it. You can use throttle function from lodash to prevent some performance issues as well.

const element = document.getElementById("element-id");

document.addEventListener(
  "scroll",
  _.throttle(e => {
    element.classList.toggle(
      "is-sticky",
      element.offsetTop <= window.scrollY
    );
  }, 500)
);