Event to detect when position:sticky is triggered

JavascriptJqueryHtmlCssPosition

Javascript Problem Overview


I'm using the new position: sticky (info) to create an iOS-like list of content.

It's working well and far superior than the previous JavaScript alternative (example) however as far as I know no event is fired when it's triggered, which means I can't do anything when the bar hits the top of the page, unlike with the previous solution.

I'd like to add a class (e.g. stuck) when an element with position: sticky hits the top of the page. Is there a way to listen for this with JavaScript? Usage of jQuery is fine.

Javascript Solutions


Solution 1 - Javascript

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

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

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

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)
);

Solution 5 - Javascript

After Chrome added position: sticky, it was found to be not ready enough and relegated to to --enable-experimental-webkit-features flag. Paul Irish said in February "feature is in a weird limbo state atm".

I was using the polyfill until it become too much of a headache. It works nicely when it does, but there are corner cases, like CORS problems, and it slows page loads by doing XHR requests for all your CSS links and reparsing them for the "position: sticky" declaration that the browser ignored.

Now I'm using ScrollToFixed, which I like better than StickyJS because it doesn't mess up my layout with a wrapper.

Solution 6 - Javascript

There is currently no native solution. See https://stackoverflow.com/questions/25308823/targeting-positionsticky-elements-that-are-currently-in-a-stuck-state. However I have a CoffeeScript solution that works with both native position: sticky and with polyfills that implement the sticky behavior.

Add 'sticky' class to elements you want to be sticky:

.sticky {
  position: -webkit-sticky;
  position: -moz-sticky;
  position: -ms-sticky;
  position: -o-sticky;
  position: sticky;
  top: 0px;
  z-index: 1;
}

CoffeeScript to monitor 'sticky' element positions and add the 'stuck' class when they are in the 'sticky' state:

$ -> new StickyMonitor

class StickyMonitor

  SCROLL_ACTION_DELAY: 50

  constructor: ->
    $(window).scroll @scroll_handler if $('.sticky').length > 0

  scroll_handler: =>
    @scroll_timer ||= setTimeout(@scroll_handler_throttled, @SCROLL_ACTION_DELAY)

  scroll_handler_throttled: =>
    @scroll_timer = null
    @toggle_stuck_state_for_sticky_elements()

  toggle_stuck_state_for_sticky_elements: =>
    $('.sticky').each ->
      $(this).toggleClass('stuck', this.getBoundingClientRect().top - parseInt($(this).css('top')) <= 1)

NOTE: This code only works for vertical sticky position.

Solution 7 - Javascript

I came up with this solution that works like a charm and is pretty small. :)

No extra elements needed.

It does run on the window scroll event though which is a small downside.

apply_stickies()

window.addEventListener('scroll', function() {
    apply_stickies()
})

function apply_stickies() {
    var _$stickies = [].slice.call(document.querySelectorAll('.sticky'))
    _$stickies.forEach(function(_$sticky) {
        if (CSS.supports && CSS.supports('position', 'sticky')) {
            apply_sticky_class(_$sticky)
        }
    })
}

function apply_sticky_class(_$sticky) {
    var currentOffset = _$sticky.getBoundingClientRect().top
    var stickyOffset = parseInt(getComputedStyle(_$sticky).top.replace('px', ''))
    var isStuck = currentOffset <= stickyOffset

    _$sticky.classList.toggle('js-is-sticky', isStuck)
}

Note: This solution doesn't take elements that have bottom stickiness into account. This only works for things like a sticky header. It can probably be adapted to take bottom stickiness into account though.

Solution 8 - Javascript

I know it has been some time since the question was asked, but I found a good solution to this. The plugin stickybits uses position: sticky where supported, and applies a class to the element when it is 'stuck'. I've used it recently with good results, and, at time of writing, it is active development (which is a plus for me) :)

Solution 9 - Javascript

I'm using this snippet in my theme to add .is-stuck class to .site-header when it is in a stuck position:

// noinspection JSUnusedLocalSymbols
(function (document, window, undefined) {

    let windowScroll;

    /**
     *
     * @param element {HTMLElement|Window|Document}
     * @param event {string}
     * @param listener {function}
     * @returns {HTMLElement|Window|Document}
     */
    function addListener(element, event, listener) {
        if (element.addEventListener) {
            element.addEventListener(event, listener);
        } else {
            // noinspection JSUnresolvedVariable
            if (element.attachEvent) {
                element.attachEvent('on' + event, listener);
            } else {
                console.log('Failed to attach event.');
            }
        }
        return element;
    }

    /**
     * Checks if the element is in a sticky position.
     *
     * @param element {HTMLElement}
     * @returns {boolean}
     */
    function isSticky(element) {
        if ('sticky' !== getComputedStyle(element).position) {
            return false;
        }
        return (1 >= (element.getBoundingClientRect().top - parseInt(getComputedStyle(element).top)));
    }

    /**
     * Toggles is-stuck class if the element is in sticky position.
     *
     * @param element {HTMLElement}
     * @returns {HTMLElement}
     */
    function toggleSticky(element) {
        if (isSticky(element)) {
            element.classList.add('is-stuck');
        } else {
            element.classList.remove('is-stuck');
        }
        return element;
    }

    /**
     * Toggles stuck state for sticky header.
     */
    function toggleStickyHeader() {
        toggleSticky(document.querySelector('.site-header'));
    }

    /**
     * Listen to window scroll.
     */
    addListener(window, 'scroll', function () {
        clearTimeout(windowScroll);
        windowScroll = setTimeout(toggleStickyHeader, 50);
    });

    /**
     * Check if the header is not stuck already.
     */
    toggleStickyHeader();


})(document, window);

Solution 10 - Javascript

@vsync 's excellent answer was almost what I needed, except I "uglify" my code via Grunt, and Grunt requires some older JavaScript code styles. Here is the adjusted script I used instead:

var stickyElm = document.getElementById('header');
var observer = new IntersectionObserver(function (_ref) {
	var e = _ref[0];
	return e.target.classList.toggle('isSticky', e.intersectionRatio < 1);
}, {
	threshold: [1]
});
observer.observe( stickyElm );

The CSS from that answer is unchanged

Solution 11 - Javascript

Something like this also works for a fixed scroll height:

// select the header
const header = document.querySelector('header');
// add an event listener for scrolling
window.addEventListener('scroll', () => {
  // add the 'stuck' class
  if (window.scrollY >= 80) navbar.classList.add('stuck');
  // remove the 'stuck' class
  else navbar.classList.remove('stuck');
});

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionAlecRustView Question on Stackoverflow
Solution 1 - JavascriptvsyncView Answer on Stackoverflow
Solution 2 - JavascriptmattrickView Answer on Stackoverflow
Solution 3 - JavascriptScott LView Answer on Stackoverflow
Solution 4 - JavascriptJassim Abdul LatheefView Answer on Stackoverflow
Solution 5 - JavascriptTuradgView Answer on Stackoverflow
Solution 6 - JavascriptWill KoehlerView Answer on Stackoverflow
Solution 7 - JavascriptDaniel TononView Answer on Stackoverflow
Solution 8 - JavascriptDaveyView Answer on Stackoverflow
Solution 9 - JavascriptSerge LiatkoView Answer on Stackoverflow
Solution 10 - JavascriptStephen RView Answer on Stackoverflow
Solution 11 - JavascriptaxieaxView Answer on Stackoverflow