34

In the code below, I'm checking to see if the window is being scrolled past a certain point and if it is, change an element to use fixed position so that it doesn't scroll off the top of the page. The only problem is that is seems to be HIGHLY client-side-memory intensive (and really bogs down the scrolling speed) because at every single scroll pixel I am updating the style attributes over and over on the element.

Would checking if the attr is already there before attempting to update it make a significant difference? Is there a completely different and more efficient practice to get the same result?

$(window).scroll(function () {
    var headerBottom = 165;
    var fcHeight = $("#pnlMainNavContainer").height();

    var ScrollTop = $(window).scrollTop();
    if (ScrollTop > headerBottom) {
        $("#HeaderContentBuffer").attr("style", "margin-top:" + (fcHeight) + "px;");
        $("#AddFieldsContainer").attr("style", "position:fixed;width:320px;top:70px;left:41px;");
    } else {
        $("#HeaderContentBuffer").attr("style", "margin-top: 0px;");
        $("#AddFieldsContainer").removeAttr("style");
    }
});

As I'm typing this, I notice that StackOverflow.com using the same type of functionality with their yellow "Similar Questions" and "Help" menus on the right hand side of this page. I wonder how they do it.

RichC
  • 7,829
  • 21
  • 85
  • 149

5 Answers5

60

One technique you can use is to set a timer on the scroll event and only do the main work when the scroll position hasn't changed for a short period of time. I use that technique on resize events which have the same issue. You can experiment with what timeout value seems to work right. A shorter time updates with shorter pauses in scrolling and thus may run more often during the scroll, a longer time requires the user to actually pause all motion for a meaningful time. You will have to experiment with what timeout value works best for your purposes and it would be best to test on a relatively slow computer since that's where the issue of scroll lag would be most pronounced.

Here's the general idea how this could be implemented:

var scrollTimer = null;
$(window).scroll(function () {
    if (scrollTimer) {
        clearTimeout(scrollTimer);   // clear any previous pending timer
    }
    scrollTimer = setTimeout(handleScroll, 500);   // set new timer
});

function handleScroll() {
    scrollTimer = null;
    var headerBottom = 165;
    var fcHeight = $("#pnlMainNavContainer").height();

    var ScrollTop = $(window).scrollTop();
    if (ScrollTop > headerBottom) {
        $("#HeaderContentBuffer").attr("style", "margin-top:" + (fcHeight) + "px;");
        $("#AddFieldsContainer").attr("style", "position:fixed;width:320px;top:70px;left:41px;");
    } else {
        $("#HeaderContentBuffer").attr("style", "margin-top: 0px;");
        $("#AddFieldsContainer").removeAttr("style");
    }
}

You may also be able to speed up your scroll function by caching some of the selectors when the scrolling first starts so they don't have to be recalculated each time. This is one place where the extra overhead of creating a jQuery object each time might not be helping you.


Here's a jQuery add-on method that handles the scrolling timer for you:

(function($) {
    var uniqueCntr = 0;
    $.fn.scrolled = function (waitTime, fn) {
        if (typeof waitTime === "function") {
            fn = waitTime;
            waitTime = 500;
        }
        var tag = "scrollTimer" + uniqueCntr++;
        this.scroll(function () {
            var self = $(this);
            var timer = self.data(tag);
            if (timer) {
                clearTimeout(timer);
            }
            timer = setTimeout(function () {
                self.removeData(tag);
                fn.call(self[0]);
            }, waitTime);
            self.data(tag, timer);
        });
    }
})(jQuery);

Working demo: http://jsfiddle.net/jfriend00/KHeZY/

Your code would then be implemented like this:

$(window).scrolled(function() {
    var headerBottom = 165;
    var fcHeight = $("#pnlMainNavContainer").height();

    var ScrollTop = $(window).scrollTop();
    if (ScrollTop > headerBottom) {
        $("#HeaderContentBuffer").attr("style", "margin-top:" + (fcHeight) + "px;");
        $("#AddFieldsContainer").attr("style", "position:fixed;width:320px;top:70px;left:41px;");
    } else {
        $("#HeaderContentBuffer").attr("style", "margin-top: 0px;");
        $("#AddFieldsContainer").removeAttr("style");
    }
});
jfriend00
  • 683,504
  • 96
  • 985
  • 979
  • Brilliant code! I love it! I found that not resetting scrollTimer to null in handleScroll() doesn't seem to affect the functionality. – Hengjie Jul 16 '12 at 23:43
  • 1
    @Hengjie - not resetting `scrollTimer` when the timer fires potentially causes you to later call `clearTimeout()` on a timer object that is no longer active. You aren't supposed to do that, thus it is more correct to set to `null` so we know the timer is no longer active and won't call `clearTimeout()` on it in the future. Probably browsers are protecting against bad calls to `clearTimeout()`, but I like to code correctly. – jfriend00 Jul 16 '12 at 23:48
  • I added a jQuery add-on method that would handle the delayed scrolling for you. – jfriend00 Oct 18 '12 at 13:09
  • Updated jQuery add-on method to make the `waitTime` arg optional, to support multiple scroll handlers on the same object and to pass `this` to the callback function. Also added [jsFiddle demo](http://jsfiddle.net/jfriend00/KHeZY/). – jfriend00 Dec 06 '13 at 16:21
  • Beautiful answer, but 50ms is more than enough, 500ms is too long (imho) – SleepyCal Nov 23 '14 at 15:08
  • This is a horrible answer. If you actually log the output this logic waits until the scroll is 'complete' (i.e. the user has stopped scrolling) before the handleScroll() function is called at all. To reiterate, the handleScroll() function is *never* called while the user is scrolling. This is almost certainly not what you want. – Fred Stevens-Smith Jan 27 '15 at 03:42
  • 1
    @FredStevens-Smith - there are ONLY two options. You either get scroll events called continually with tons of updates while the user scrolls which creates a really laggy experience if you're doing much drawing in the scroll handler OR you set a time delay, however small you want so that when the user pauses scrolling, then it will redraw. This is a classic design pattern and is used by tons of applications. If you want lots of redraws, you can set the delay as short as you want as it is an argument to the function. – jfriend00 Jan 27 '15 at 04:50
  • 1
    @FredStevens-Smith - Look at the OP's issue - it got really laggy because they were processing too many scroll events. The only way to solve that in the browser is with a small time delay to skip handling some events. Perhaps you thought the default time delay was too long - you are welcome to make it shorter. If the action you are carrying out in the scroll event is not compute or draw-intensive, you can get away with a much shorter time delay. But, if it takes the browser a second to update the screen, then you can't draw on every scroll event. This is a common solution for this issue. – jfriend00 Jan 27 '15 at 04:52
  • 1
    @FredStevens-Smith - This solution worked well for the OP. if you have a better solution, please propose it as an answer (we're all ears). If not, you might want to rethink your criticism and your downvote until you have a better solution. – jfriend00 Jan 27 '15 at 04:53
  • 1
    @sleepycal - the most appropriate time delay really depends upon what is being done in the scroll event handler. If it is doing a very quick calculation or update, then 50ms is probably ideal. If it's doing something that takes the browser 1 second to relayout and redraw, then 50ms is way too short and will create a laggy, jerky experience. That's why the delay is an argument that you can set as appropriate for your operation. – jfriend00 Jan 27 '15 at 05:05
  • The reason it doesn't fire until after scrolling is because of these lines: `if (scrollTimer) { clearTimeout(scrollTimer); ` What it's doing is on every pixel scrolled, it resets the timer. I would remove that bit of code and it will update every 500ms like it's supposed to. Am I crazy? – Sean Kendle Aug 08 '16 at 16:27
  • I would say change it to this: `if(scrollTimer == null) scrollTimer = setTimeout(handleScroll, 500);` – Sean Kendle Aug 08 '16 at 16:28
  • @SeanKendle - You are proposing a different screen update strategy. The strategy for this answer is to only update the screen some number of ms AFTER the user stops moving the scroll bar. So, as long as the user is continuing to move the scroll bar, don't do your work no matter how long they keep playing with the scrollbar. The idea is to detect when the user has either finished moving the scrollbar or has paused for a long enough time. Your strategy could also be useful in some circumstances, but the idea here was no update if the scrollbar is still being changed. – jfriend00 Aug 08 '16 at 16:49
  • Nowhere is it said in the original post that he wants to update only after scrolling. – Sean Kendle Aug 08 '16 at 17:28
  • Also, you seem to suggest in your answer that it will update during scrolling: "You can experiment with what timeout value seems to work right. A shorter time updates more frequently but runs more often *during the scroll*, a longer time doesn't run as often. " – Sean Kendle Aug 08 '16 at 18:02
  • @SeanKendle - Try the jsFiddle yourself with a short time of 100ms. It will update if you just momentarily pause your scrolling motion. Set it to 500ms and it takes a long pause to trigger an update. That's how it works. This is a very commonly used technique. There are other strategies too. The concept is right in the first sentence of the answer: ***only do the main work when the scroll position hasn't changed for a short period of time***. I updated some of the other wording to reinforce this same concept. – jfriend00 Aug 08 '16 at 18:32
  • That seems to clarify what you're doing a bit more, but I might offer my own answer to this topic because I think many people may find it useful to have a more efficient approach to scrolling that updates *while scrolling*, without having to pause to update. I appreciate that your answer is taking a different tact, but I think others would benefit from such an answer as I describe. – Sean Kendle Aug 08 '16 at 19:16
11

I have found this method to be much more efficeint for $(window).scroll()

var userScrolled = false;

$(window).scroll(function() {
  userScrolled = true;
});

setInterval(function() {
  if (userScrolled) {

    //Do stuff


    userScrolled = false;
  }
}, 50);

Check out John Resig's post on this topic.

An even more performant solution would be to set a a longer interval that detects if you are close to the bottom or top of the page. That way, you wouldn't even have to use $(window).scroll()

daralthus
  • 13,723
  • 2
  • 16
  • 13
Sam Heuck
  • 585
  • 5
  • 15
  • 2
    why keep running a function even when the scroll did not happen just to check if scroll happened? I prefer the selected answer approach. – Lucky Soni Dec 06 '13 at 16:17
  • yeah, I agree with Lucky – Jair Reina Jan 04 '14 at 04:46
  • 1
    The selected answer didn't work for my case, as the selected answer doesn't fire any event if scrolling is still in progress. I needed my event to fire as soon as possible, not when scrolling has ended. Thanks Sam! – newpoison Jun 25 '14 at 19:52
  • Depends which is more performance intensive, you'd have to benchmark 50ms timer vs a clearInterval() on the average number of scroll events etc. – SleepyCal Nov 23 '14 at 15:09
  • @newpxsn - check out my comment below the accepted answer, does that make sense? – Sean Kendle Aug 08 '16 at 16:34
2

Making your function a little more efficient.

Just check if the style attribute is present/absent before removing/adding the styles.

$(window).scroll(function () {
    var headerBottom = 165;
    var fcHeight = $("#pnlMainNavContainer").height();

    var ScrollTop = $(window).scrollTop();
    if (ScrollTop > headerBottom) {
        if (!$("#AddFieldsContainer").attr("style")) {
            $("#HeaderContentBuffer").attr("style", "margin-top:" + (fcHeight) + "px;");
            $("#AddFieldsContainer").attr("style", "position:fixed;width:320px;top:70px;left:41px;");
        }
    } else {
        if ($("#AddFieldsContainer").attr("style")) {
            $("#HeaderContentBuffer").attr("style", "margin-top: 0px;");
            $("#AddFieldsContainer").removeAttr("style");
        }
    }
});
Hemant Aggarwal
  • 849
  • 1
  • 7
  • 13
1

Set some logic here. You actually need to set atts once on up, and once on down. So:

var checker = true;
$(window).scroll(function () {

    .......

    if (ScrollTop > headerBottom && checker == true) {
        $("#HeaderContentBuffer").attr("style", "margin-top:" + (fcHeight) + "px;");
        $("#AddFieldsContainer").attr("style", "position:fixed;width:320px;top:70px;left:41px;");
        checker == false;
    } else if (ScrollTop < headerBottom && checker == false) {
        $("#HeaderContentBuffer").attr("style", "margin-top: 0px;");
        $("#AddFieldsContainer").removeAttr("style");
        checker == true;
    }   
});
1

The following answer does not use jQuery, but achieves the same result using modern browser features. The OP also asked if there is a different and more efficient method. The following method is much more performant than jQuery or even a plain JavaScript solution using an onScroll event listener.

We'll use a combo of 2 great things:

  • the position: sticky' CSS property
  • the IntersectionObserver API

To achieve the effect of the header becoming stuck we can use the amazing combo of position: sticky; top: 0px; CSS properties. This will allow an element to scroll with the page and become stuck (as if it's fixed) when it reaches the top of the page.

To change the styles of the stuck element, or any other element, we can use the IntersectionObserver API - which allows us to observe changes in the intersection of 2 elements.

To achieve the effect we want, we'll add a sentinel element which will serve as an indicator when the header element reaches the top. In other words:

  • when the sentinel is not intersecting the viewport, our header is at the top
  • when the sentinel is intersecting the viewport, our header is not at the top

With these 2 conditions, we can apply any necessary styles to the header or other elements.

const sentinelEl = document.getElementById('sentinel')
const headerEl = document.getElementById('header')
const stuckClass = "stuck"

const handler = (entries) => {
  if (headerEl) {
    if (!entries[0].isIntersecting) {
      headerEl.classList.add(stuckClass)
    } else {
      headerEl.classList.remove(stuckClass)
    }
  }
}

const observer = new window.IntersectionObserver(handler)
observer.observe(sentinelEl)
html,
body {
  font-family: Arial;
  padding: 0;
  margin: 0;
}

.topContent,
.pageContent,
#header {
  padding: 10px;
}

#header {
  position: sticky;
  top: 0px;
  transition: all 0.2s linear;
}

#header.stuck {
  background-color: red;
  color: white;
  
}

.topContent {
  height: 50px;
  background-color: gray;
}

.pageContent {
  height: 600px;
  background-color: lightgray;
}
<div class="topContent">
  Content above our sticky element
</div>
<div id="sentinel"></div>
<header id="header">
  Sticky header
</header>
<div class="pageContent">
  The rest of the page
</div>
Brett DeWoody
  • 59,771
  • 29
  • 135
  • 184