109

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.

Adam
  • 6,041
  • 36
  • 120
  • 208
Alec Rust
  • 10,532
  • 12
  • 48
  • 63
  • 1
    It's funny because the top rated comment on that article solves your problem exactly. That guy got it spot on, this should be a media query, not a property. That way you could alter styles when the element gets stuck (which we often do). Oh well, a man can dream. – Christian Apr 30 '13 at 16:44
  • 1
    Yeah, I noticed that comment, his proposal seems far better. Still, `position: sticky` is what Chrome's implemented so I'm looking for a way to make it usable! – Alec Rust Apr 30 '13 at 16:46
  • 4
    Am I dumb?! What article is the first commenter talking about?! – katerlouis Dec 10 '19 at 09:56
  • 1
    @katerlouis No! I think it's a case of link rot, or comment removal. – Brian Zelip Jul 19 '20 at 20:08
  • 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 – Scott L Oct 02 '17 at 18:00

11 Answers11

140

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

vsync
  • 118,978
  • 58
  • 307
  • 400
  • Why is the "trick" needed? What happens if you use `top: 0` and `padding-top: 1em`? – Daniel Tonon Sep 21 '19 at 08:53
  • @DanielTonon - From [MDN](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry/intersectionRatio) ▶ "*intersectionRatio property tells you how much of the target element is currently visible within the root's intersection ratio, as a value between 0.0 and 1.0.*" ▶ Only `top` property will offset the element, thus making sure it will never be 100% visible (less than `1`) – vsync Sep 21 '19 at 09:42
  • 3
    Ok right, if you set `top: 0` then it will never intersect with the top of the browser window. Setting `top: -1px` will allow for `1px` worth of intersection (though `1px` worth of the content will not be visible thus the need to compensate for the trick). Clever – Daniel Tonon Sep 21 '19 at 09:55
  • 3
    You could avoid the calc function by using `border-top: 1px solid transparent` instead. – Daniel Tonon Sep 21 '19 at 10:03
  • 2
    I made some edits. I clarified what the "trick" was and why it was needed. I also clarified for people what parts of the CSS were required vs what was superfluous (I was thinking of deleting the unnecessary stuff for clarity). Lastly making the "demo" word a link makes it easier for people to access in a more intuitive way than scrolling all the way to the end of the answer to access the live demo. All of these things make the answer more useful to people. – Daniel Tonon Sep 21 '19 at 14:20
  • I compress my scripts with Grunt Uglify, and Grunt doesn't grok newer JS code styles. I've added an Answer, below, that shows a modified version of this script, that will work properly with Grunt Uglify. – Stephen R Jul 02 '20 at 15:23
  • 1
    @StephenR nobody uses Grunt anymore since 2015 ‎ – vsync Jul 03 '20 at 08:41
  • 1
    How will this work if top already has a positive value? – BritishSam Sep 15 '20 at 15:08
  • @BritishSam - use `transform` instead to position it from the top then. or `margin`. – vsync Sep 16 '20 at 08:31
  • Just an FYI that the React hook in the demo uses `createRef()` when it should instead (ideally) use `useRef`. – Stephen Watkins Jun 28 '21 at 21:47
  • 3
    The problem with this is that isSticky is also applied if the element is out of view further down the page. This can cause issues and flashes of content if you're relying on the class to show/hide stuff on scroll. – Flowgram Jul 20 '21 at 12:30
  • 2
    Solved as follows: `e.target.classList.toggle('isSticky', e.boundingClientRect.top < 0)` – Flowgram Jul 20 '21 at 12:46
  • Why are you using `height: 200vh;` in your `body`? – Mauricio Betancur Molina Jan 03 '22 at 16:59
  • @MauricioBetancurMolina - to make sure a scrollbar will be rendered in the demo – vsync Jan 03 '22 at 18:14
  • 1
    FYI, the demo "alternate" method using rootMargin doesn't seem to work – Rollie Jun 13 '22 at 04:05
  • 1
    Unfortunately, `IntersectionObserver` method does not always toggle class or attribute properly – it happens during fast scrolling. At least I have this issue using Chrome. – Ryba Jan 09 '23 at 13:07
  • @Ryba - [Report it as a bug](https://bugs.chromium.org/p/chromium/issues/list) then with reproducible minimal demo. – vsync Jan 09 '23 at 13:14
  • Per [this answer](https://stackoverflow.com/a/61115077/1341949), you can just set [the IntersectionObserver's `rootMargin` option](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/rootMargin) to `-1px 0 0 0` to get around the `top:-1px` offset hack! – Rafe Goldberg Feb 12 '23 at 21:45
  • 1
    @RafeGoldberg - It's mentioned in my answer, in the part (year before the answer you linked to) – vsync Feb 12 '23 at 22:07
62

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);
mattrick
  • 3,580
  • 6
  • 27
  • 43
  • 10
    Can you provide an example? This does not seem to work for me. – Sơn Trần-Nguyễn May 04 '20 at 15:34
  • 3
    most elegant answer imho – rx2347 Oct 09 '20 at 14:49
  • 1
    This works when my sticky is on the right but it doesn't work when the sticky element is on the left (horizontally scrolled, obviously with adjusted rootMargins). When it is on the left, it is always intersecting so I can never determine when it sticks. Root margin makes no difference. – Fygo Jan 15 '21 at 21:52
  • mixed with vsync response; works perfect :) – Edwin Joassart Dec 02 '21 at 15:53
  • Hi, it works, but as this requires much more code, and since the `top` CSS property has to be written anyway, then it's much shorter to simply change `top:0` to `top:-1` and call it a day ;) unless this small CSS change causes some visual bug in your design – vsync Jan 03 '22 at 07:21
  • I needed to do the same but the header behaves at sticky viewport + some top property. can someone help me how to achieve that. Detailed SO question - https://stackoverflow.com/questions/72158078/intersectionobserver-toggle-a-class-when-sticky-element-intersects-with-viewpo – theFrontEndDev May 08 '22 at 04:35
  • @vsync `top:-1` doesn't mean anything in modern CSS. Did you mean `top: -1px` or something else? – Mikko Rantalainen Dec 21 '22 at 09:18
  • Note that the 2nd parameter for IntersectionObserver() constructor should also define the `root` property. You can use `null` for the viewport or any *ancestor* element if you want to compute intersection against that one. If you pass a non-ancestor element, you will not get any errors but the callback is never called. – Mikko Rantalainen Dec 21 '22 at 10:25
  • Yes my meaning was `-1px` – vsync Dec 21 '22 at 11:22
  • Here's a demo for this technique: https://css-tricks.com/how-to-detect-when-a-sticky-element-gets-pinned/ – This demo doesn't use `rootMargin` so it's off-by-one pixel. – Mikko Rantalainen Dec 21 '22 at 14:42
  • 3
    This answer will not work for an element that is flush to the top of the page. For example, a navbar. It will always be considered activated. – Dan Feb 16 '23 at 08:31
3

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.

Daniel Tonon
  • 9,261
  • 5
  • 61
  • 64
  • 1
    Great answer. The answer put forward by Google is good, but it's overkill for most purposes. Plus this has IE11 support. – png Mar 06 '19 at 17:52
  • I wouldn't really call it IE11 support. It's more like it just won't cause the site to crash if viewed in IE11. IE11 doesn't support `position: sticky`. – Daniel Tonon Mar 07 '19 at 14:11
  • 1
    ▶ scroll event callback should probably be **throttled** since intense calculations are being done there. Also, a developer would have to actively cleanup the event binding and re-bind on DOM changes, because sticky elements might be appended to the DOM *after** this function had run. Also.. this code is *far** from optimal and can be much refactored for performance & readability – vsync Sep 18 '19 at 10:21
  • It can't be throttled if you want a smooth transition. It doesn't seem to perform that badly in my experience. DOM elements *might* be appended. The vast majority of the time that isn't the case though. If you know a much better way to do the same thing then feel free to provide your own answer. – Daniel Tonon Sep 18 '19 at 10:34
  • I've provided my own answer :) – vsync Sep 18 '19 at 11:38
  • I think it would be faster to do the forEach loop of the the _$stickies array inside of a single scroll listener, rather than creating a listener for each sticky. – Jordan Apr 13 '20 at 18:54
  • 1
    Good point @Jordan, I've updated my answer based on your suggestion. – Daniel Tonon Apr 14 '20 at 00:02
  • Nice, one more optimization would be to only create the scroll listener if CSS.supports is true – Jordan Apr 15 '20 at 13:16
2

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.

Community
  • 1
  • 1
Turadg
  • 7,471
  • 2
  • 48
  • 49
2

There is currently no native solution. See Targeting position:sticky 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.

Community
  • 1
  • 1
Will Koehler
  • 1,746
  • 22
  • 16
  • This solution has the advantage of (seeming to me to) coping with window resize and page reflow, unlike those that inspect and save `offsetTop`. – BigglesZX Jun 25 '20 at 11:30
1

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

Davey
  • 2,355
  • 1
  • 17
  • 18
  • One gotcha with stickybits is that I've found it doesn't work brilliantly when you have other js listening to scroll events. These issues only became apparent when I tested in a browser that didn't support `position: sticky` (ie11, edge). – Davey Aug 31 '17 at 08:04
  • 3
    This lib is buggy as **** – Vahid Amiri Sep 02 '17 at 22:07
  • It has been forked and is maintained [here](https://github.com/yowainwright/stickybits), I quite like this option. – Alec Rust Feb 01 '23 at 11:45
1

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

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

m4n0
  • 29,823
  • 27
  • 76
  • 89
  • As it’s currently written, your answer is unclear. Please [edit] to add additional details that will help others understand how this addresses the question asked. You can find more information on how to write good answers [in the help center](/help/how-to-ask). – Community Sep 13 '21 at 20:18
0

You can do this very simply like so:

  1. Attach a scroll event listener
  2. Get the desired sticky top position of the element via getComputedStyle()
  3. Get the current viewport offset of the element using getBoundingClientRect()
  4. Add the class if both values match using classList.toggle()

Here's how it looks like in code:

const el = document.querySelector(".my-sticky-element");
window.addEventListener("scroll", () => {
    const stickyTop = parseInt(window.getComputedStyle(el).top);
    const currentTop = el.getBoundingClientRect().top;
    el.classList.toggle("is-sticky", currentTop === stickyTop);
});
dodov
  • 5,206
  • 3
  • 34
  • 65
-1

@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

Stephen R
  • 3,512
  • 1
  • 28
  • 45
-2

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');
});
axieax
  • 19
  • 4