Optimising a panel "scroll to bottom" animation and trigger

Hi all,

I’ve been able to put together a panel which has an overlay to signal to the user there is more content to scroll. When the user reaches the bottom of the scrollable content, the overlay disappears and only reappears if they scroll up again. It also adjusts depending if the scrollHeight is smaller/larger than the clientHeight

Below is the code that I have so far but it feels very inefficient to me and contains A LOT of “if/else” statements to add/remove the class which activates the animation.

Therefore, how could I optimise this?

Thanks in advance!

Well I don’t think the code is too bad and the if statements are not that unruly. However, a couple of ideas for you to think about that can optimize this a bit…

  1. You can call to add a class to an element even if the element already has it. It will not add it again.
// No need to check if it has the class, just call add class no matter what. 
// If it is not there, it will add it. If it has it, it will just do nothing.
elem.addClass('fade-in');

This goes the same for removeClass. If it is there, it is removed, if not, nothing happens.

  1. You can chain add/remove class calls onto one another.
elem.removeClass('fade-out').addClass('fade-in');
  1. You have a toggleClass() function too that maybe you can find useful somewhere. Not saying this is as useful as my other two points, but depending on design, you might find it useful.

Good luck and remember, don’t over optimize at the expense of readability! :slight_smile:

1 Like

Let’s take a look at cleaning up some of the code.

Linter

I’ll run the code through a linter, to help take care of obvious issues. Those are:

  • use double quotes instead of single quotes
  • reduce length of long lines
  • give names to anonymous functions

I’ve also done a few things to help reduce some long lines:

  • use querySelector instead of getElementById and getElementsByClassName
  • replace if conditions with function calls

The linting is all done and the current state of the code is:

// cinema search mask fade in/out
function cinemaSearchMask() {
    var panelSelector = "#cinemas__search__scroll";
    var overlaySelector = ".cinemas__search__scrolloverlay";
    var scrollPanel = document.querySelector(panelSelector);
    var scrollOverlay = document.querySelector(overlaySelector);

    function hasVisibleScrollbar(scrollPanel) {
        return scrollPanel.scrollHeight > scrollPanel.clientHeight;
    }
    // if scrollbar visible
    if (hasVisibleScrollbar(scrollPanel)) {
        $(scrollOverlay).addClass("fade-in");
    } else {
        // if scrollbar not visible, remove fade-in on overlay
        $(scrollOverlay).removeClass("fade-in");
    }

    $(scrollPanel).on("scroll", function panelScrollHandler() {
        function atScrollEnd(scrollPanel) {
            var scrollSize = scrollPanel.scrollHeight - scrollPanel.scrollTop;
            return scrollSize - scrollPanel.clientHeight < 1;
        }
        if (atScrollEnd(scrollPanel)) {
            // if scroll finished, fade-out overlay
            $(scrollOverlay).addClass("fade-out");
        } else {
            // check if fade-in active and add if not active
            if ($(scrollOverlay).hasClass("fade-in")) {
                $(scrollOverlay).removeClass("fade-out");
            } else {
                $(scrollOverlay).addClass("fade-in");
            }
        }
    });
}

cinemaSearchMask();
$(window).resize(function resizeHandler() {
    cinemaSearchMask();
});

There are still some easy improvements that should be done.

Simplify comments

Here is one of the comments in the code:

    // if scrollbar visible
    if (hasVisibleScrollbar(scrollPanel)) {
        $(scrollOverlay).addClass("fade-in");

That comment now basically duplicates what we already see in the code. Thanks to the function call, that comment now serves no benefit so it gets removed.

The next comment to consider is in this code:

    if (hasVisibleScrollbar(scrollPanel)) {
        $(scrollOverlay).addClass("fade-in");
    } else {
        // if scrollbar not visible, remove fade-in on overlay
        $(scrollOverlay).removeClass("fade-in");
    }

Instead of using addClass and removeClass, we can use toggleClass instead. To use toggleClass we want an easy way to tell when the class should be added. In this case it’s when the scrollbar is visible:

    var scrollbarIsVisible = hasVisibleScrollbar(scrollPanel);
    $(scrollOverlay).toggleClass("fade-in", scrollbarIsVisible);

That now replaces the previous if/else code.

The next part of the code that’s full of comments is:

        if (atScrollEnd(scrollPanel)) {
            // if scroll finished, fade-out overlay
            $(scrollOverlay).addClass("fade-out");
        } else {
            // check if fade-in active and add if not active
            if ($(scrollOverlay).hasClass("fade-in")) {
                $(scrollOverlay).removeClass("fade-out");
            } else {
                $(scrollOverlay).addClass("fade-in");
            }
        }

We can also use toggleClass to simplify all of that, to just the following:

        var isAtScrollEnd = atScrollEnd(scrollPanel);
        $(scrollOverlay).toggleClass("fade-out", isAtScrollEnd);
        $(scrollOverlay).toggleClass("fade-in", !isAtScrollEnd);

Debounce the scroll event

Moving the scroll code out of the event handler to a separate function, let’s us more easily redesign the code.

    function atScrollEnd(scrollPanel) {
        var scrollSize = scrollPanel.scrollHeight - scrollPanel.scrollTop;
        return scrollSize - scrollPanel.clientHeight < 1;
    }
    function updateScrollOverlay(scrollPanel) {
        var isAtScrollEnd = atScrollEnd(scrollPanel);
        $(scrollOverlay).toggleClass("fade-out", isAtScrollEnd);
        $(scrollOverlay).toggleClass("fade-in", !isAtScrollEnd);
    });
    $(scrollPanel).on("scroll", function panelScrollHandler() {
        updateScrollOverlay(scrollPanel);
    });

Now when we want to try and improve things about how the scroll event works, it’s only the following part that we need to deal with:

    $(scrollPanel).on("scroll", function panelScrollHandler() {
        updateScrollOverlay(scrollPanel);
    });

By placing a console.log statement in the scroll handler, I can see that a single scroll causes the update to run 12 times in a row. There is absolutely no need for the code to run all of those times.

There are a couple of ways to deal with this. One is to set a variable that the overlay has been updated, and clear that variable a few times per second. That way we can check if the update has already occurred recently and not do it, until some time has passed.

We can use a debounce function to achieve that.

    var debouncedPanelUpdate = debounce(updateScrollOverlay, 50);
    $(scrollPanel).on("scroll", function panelScrollHandler() {
        debouncedPanelUpdate(scrollPanel);
    });

And now the update is done only a few times when you scroll, instead of hundreds of times.

Summary

I’ve forked the codePen and the updated code is found at https://codepen.io/pmw57/pen/zYZxZvX

Here’s what the updated code looks like:

// Returns a function, that, as long as it continues to be invoked, will not
// be triggered. The function will be called after it stops being called for
// N milliseconds. If `immediate` is passed, trigger the function on the
// leading edge, instead of the trailing.
function debounce(func, wait, immediate) {
	var timeout;
	return function() {
		var context = this, args = arguments;
		var later = function() {
			timeout = null;
			if (!immediate) func.apply(context, args);
		};
		var callNow = immediate && !timeout;
		clearTimeout(timeout);
		timeout = setTimeout(later, wait);
		if (callNow) func.apply(context, args);
	};
};

// cinema search mask fade in/out
function cinemaSearchMask() {
    var panelSelector = "#cinemas__search__scroll";
    var overlaySelector = ".cinemas__search__scrolloverlay";
    var scrollPanel = document.querySelector(panelSelector);
    var scrollOverlay = document.querySelector(overlaySelector);

    function hasVisibleScrollbar(scrollPanel) {
        return scrollPanel.scrollHeight > scrollPanel.clientHeight;
    }
    var scrollbarIsVisible = hasVisibleScrollbar(scrollPanel);
    $(scrollOverlay).toggleClass("fade-in", scrollbarIsVisible);

    function atScrollEnd(scrollPanel) {
        var scrollSize = scrollPanel.scrollHeight - scrollPanel.scrollTop;
        return scrollSize - scrollPanel.clientHeight < 1;
    }
    function updateScrollOverlay(scrollPanel) {
        var isAtScrollEnd = atScrollEnd(scrollPanel);
        $(scrollOverlay).toggleClass("fade-out", isAtScrollEnd);
        $(scrollOverlay).toggleClass("fade-in", !isAtScrollEnd);
    }
    var debouncedPanelUpdate = debounce(updateScrollOverlay, 50);
    $(scrollPanel).on("scroll", function panelScrollHandler() {
        debouncedPanelUpdate(scrollPanel);
    });
}

cinemaSearchMask();
$(window).resize(function resizeHandler() {
    cinemaSearchMask();
});
1 Like

How is doing that reducing some long lines? The number of characters of code is exactly the same (when you include the extra ‘#’ for the selector). Anyway it’s less readable.

For the class selector getElementsByClassName there is a significant difference. Beyond that, it’s about consistency with CSS selectors.

In other projects when using querySelectorAll, they are directly iterable using forEach too. which leads to some benefits too.

For example:

document.querySelectorAll(".panel").forEach(initPanel);
1 Like

Wow, amazing replies and really insightful! Apologies it’s taken me a few days to reply - really appreciate all of the feedback and will be sure to carry this over to my future JS projects.

Special thanks to Paul for the full explanation and breakdown of my code.

This topic was automatically closed 91 days after the last reply. New replies are no longer allowed.