Modifying a current site header on scroll

Hi there,

I’ve been tasked with modifying an existing header for a test, so that it becomes a slimmer version on scroll. However, the existing header involves search functionality - some of which is reliant on the search button being clicked for it to appear. The search itself causes the header to expand, affecting the scroll position as a result which adds further complication.

At the moment I’m trying to optimise my code as much as possible so that it doesn’t continually add the necessary CSS class to make it slim on scroll (only when it reaches the scrollThreshold), while also ensuring that the header only animates when this happens and not when the search is opened or closed.

The following is what I have so far which I feel needs some serious optimisation. I found that the low throttleDelay was needed so that it would fire quickly enough when scrolling to the top and back down again, but I am finding that it is multiplying the addition of the slim-header class due to the scrolling and click of the search functions. Also, interacting with the search functions cause the “animate” class to be added again, forcing the animation to run when I don’t want it to (it should remain static).

Any thoughts please?

JavaScript

(function () {
    console.log("Slim Header On Scroll - Throttled - 1062");

    slimHeaderOnScroll();
})();



// Check for element based on timeout/interval and the selector passed,
// then run the callback function as applicable
function checkForElement(selector, callback) {
    const timeout = 5000; // 5 seconds
    const interval = 100; // Check every 400 milliseconds
    let elapsedTime = 0;
    let intervalId;

    const checkElementInterval = setInterval(() => {
        const element = document.querySelector(selector);

        // If element has been found, run callback
        if (element) {
            clearInterval(intervalId);
            callback(element); // Pass the element to the callback function
        } else {
            // If over the timeout period
            if (elapsedTime >= timeout) {
                clearInterval(intervalId);
            } else {
                elapsedTime += interval;
            }
        }
    }, interval);

    intervalId = checkElementInterval;
}

function slimHeaderOnScroll() {
    const self = this;
    let throttleTimeout;
    let throttleDelay = 50; // Adjust throttle delay as needed

    console.log("throttleDelay " + throttleDelay);

    // Throttled scroll event handler
    function handleScroll() {
        if (!throttleTimeout) {
            throttleTimeout = setTimeout(function () {
                throttleTimeout = null;
                console.log(self.scrollY);
                // Initial position check and class application
                scrollPosCheck(self);
            }, throttleDelay);
        }
    }

    window.addEventListener("scroll", handleScroll.bind(this));
}

function scrollPosCheck(self) {
    const siteHeader = document.querySelector('div[data-testid="app-header"]');
    const headerSearchBtn = 'button[data-testid="app-header-desktop-search-button"]';
    const headerSearchCloseBtn = 'button[data-testid="app-header-search-bar-close"]';
    const headerSearchBox = 'div[data-testid="app-header-search-bar"] input#search';
    const headerSearchOverlay = '.sf-overlay';

    const scrollThreshold = 170;

    let searchBtnInputCheck = false;

    if (self.scrollY === 0) {
        // If top of page, change to standard header

        siteHeader.classList.remove('slim-header');
        siteHeader.querySelector('header').classList.remove('slim-header--animate');
    } else {
        // Attach event listeners
        const searchBtn = document.querySelector(headerSearchBtn);
        const searchCloseBtn = document.querySelector(headerSearchCloseBtn);
        const searchBox = document.querySelector(headerSearchBox);

        // If scroll more threshold then apply slim header styles
        if (self.scrollY > scrollThreshold) {
            siteHeader.classList.add('slim-header');
        } else if (self.scrollY <= scrollThreshold) {
            searchBtnInputCheck = false;
            console.log("searchBtnInputCheck reset: " + searchBtnInputCheck);
        }

        console.log("searchBtnInputCheck pre-animate" + searchBtnInputCheck);

        // If search not active, above threshold and searchBtn hasn't been clicked
        if (self.scrollY > scrollThreshold && !searchBtnInputCheck) {
            siteHeader.querySelector('header').classList.add('slim-header--animate');
        }

        if (searchBtn) {
            // Attribute check to see if element has already been clicked
            if (!searchBtn.hasAttribute('data-clicked')) {

                // Add attribute to mark that the event listener has been added
                // searchBtn.setAttribute('data-clicked', 'true');

                searchBtn.addEventListener('click', function () {
                    //console.log("Search button click: " + self.scrollY);

                    searchBtnInputCheck = true;

                    // If scroll position is correct, add the class
                    if (self.scrollY > scrollThreshold) {
                        siteHeader.classList.add('slim-header');
                        console.log("Search button click: Slim Header class added");
                    }

                    // Check for search close button
                    if (searchCloseBtn) {

                        // Attribute check to see if element has already been clicked
                        if (!searchCloseBtn.hasAttribute('data-clicked')) {

                            // Add attribute to mark that the event listener has been added
                            // searchCloseBtn.setAttribute('data-clicked', 'true');
                            searchCloseBtn.addEventListener('click', function () {
                                if (self.scrollY > scrollThreshold) {
                                    siteHeader.classList.add('slim-header');
                                    console.log("Search close button click: Slim Header class added");
                                }
                            });
                        }

                    }

                    // Check for search box
                    if (searchBox) {
                        // Attribute check to see if element has already been interacted with
                        if (!searchBox.hasAttribute('data-clicked')) {

                            // Add attribute to mark that the event listener has been added
                            // searchBox.setAttribute('data-clicked', 'true');

                            let searchBoxInputCheck = false;

                            const inputCheckHandler = () => {
                                if (!searchBoxInputCheck) {
                                    searchBoxInputCheck = true;

                                    console.log("Search box input: Slim Header class added");

                                    // Check for overlay and apply click event if needed
                                    checkForElement(headerSearchOverlay, function (searchOverlay) {
                                        // console.log("Search not already started: Search overlay found");

                                        // Attribute check to see if element has already been clicked
                                        if (!searchOverlay.hasAttribute('data-clicked')) {

                                            // Add attribute to mark that the event listener has been added
                                            // searchOverlay.setAttribute('data-clicked', 'true');

                                            searchOverlay.addEventListener('click', function () {
                                                if (self.scrollY > scrollThreshold) {
                                                    siteHeader.classList.add('slim-header');
                                                    console.log("Search overlay click: Slim Header class added");
                                                }
                                            });
                                        }

                                    });
                                }

                                // Remove the input event listener
                                searchBox.removeEventListener('input', inputCheckHandler);
                            };

                            // Add the input event listener
                            searchBox.addEventListener('input', inputCheckHandler);

                        }
                    }

                    if (self.scrollY > scrollThreshold) {
                        // If search has been started already then overlay will appear
                        checkForElement(headerSearchOverlay, function (searchOverlay) {
                            console.log("Search already started: Search overlay found");

                            // Attribute check to see if element has already been clicked
                            if (!searchOverlay.hasAttribute('data-clicked')) {

                                // Add attribute to mark that the event listener has been added
                                // searchOverlay.setAttribute('data-clicked', 'true');

                                searchOverlay.addEventListener('click', function () {
                                    siteHeader.classList.add('slim-header');
                                    console.log("Search overlay click: Slim Header class added");

                                });
                            }
                        });
                    }
                });
            }
        }
    }
}

CSS

@media(min-width: 992px) {
    /* Header Container */
    .slim-header--animate{
        animation: slideDown ease-in 0.3s forwards;
    }

    .slim-header header {
        padding-left: 1.5rem !important;
        padding-right: 1.5rem !important;
    }

    /* Header Inner Container */
    .slim-header header>div:first-child {
        display: grid !important;
        padding-bottom: 0 !important;
        grid-template-columns: 1fr 9fr auto auto;
    }

    /* Logo */
    .slim-header header div[data-testid="app-header-desktop-left"]~div.flex-auto {
        display: flex;
        justify-content: flex-start;
        align-items: center;
        grid-column: 1;
    }

    .slim-header header div[data-testid="app-header-desktop-left"]~div.flex-auto>div>div {
        padding-bottom: 0.25rem !important;
    }

    /* Shop Finder */
    .slim-header header div[data-testid="app-header-desktop-left"] {
        display: none !important;
    }

    /* Search, Account & Bag */
    .slim-header header div[data-testid="app-header-desktop-left"]~.w-52.flex.items-center.justify-end {
        order: 3;
        margin-left: 1rem;
        width: auto !important;
        grid-column: 4;
        justify-content: flex-start !important;
    }

    .slim-header .sf-overlay {
        top: 9rem !important
    }

    /* Navigation */
    .slim-header header>div:nth-child(2) {
        position: absolute;
        top: 25px;
        left: 0;
        width: 100%;
        pointer-events: none;
    }

    .slim-header div[data-testid="desktop-nav"] li {
        pointer-events: visible;
    }

    .slim-header div.test-mega-menu-panel {
        margin-top: -1px;
        margin-left: 0 !important;
        margin-right: 0 !important;
    }
}

@media(min-width: 992px) and (max-width: 1280px) {
    .slim-header header>div:first-child {
        padding-bottom: 1rem !important;
    }

    .slim-header header>div:nth-child(2) {
        position: relative;
        top: 0;
    }
}

@media(min-width: 1280px){
    .slim-header--animate ~ #layout{
        margin-top: 64px;
    }

    .slim-header--animate ~ #layout.push-up-page{
        margin-top: -79px;
    }
}

@keyframes slideDown{
    0%{
        transform: translateY(-145px);
    }

    100%{
        transform: translateY(0);
    }
}

Hi there,

Just to follow-up on this - I ended up breaking out the search-related lookups into their own function as I realised that part of the problem was that because it was part of the scroll-based function, this was causing the classes to be added.

I also brought all of the references up to the self-invoked function to then pass this as an object to the new functions. I also found that I had to adjust my targeting so that it looked at the siteHeader (parent element).

New code is below:

(function () {
    const sharedParams = {
        self: this,
        siteHeader: document.querySelector('div[data-testid="app-header"]'),
        siteHeaderSearchRef: 'div[data-testid=app-header-search-bar]',
        slimHeaderStyle: 'slim-header',
        slimHeaderNoAnimateStyle: 'slim-header--noanimate',
        slimHeaderAnimateStyle: 'slim-header--animate',
        scrollThreshold: 170
    };

    slimHeaderOnScroll(sharedParams);
    searchInteractionTracking(sharedParams);
})();



// Check for element based on timeout/interval and the selector passed,
// then run the callback function as applicable
function checkForElement(selector, callback) {
    const timeout = 5000; // 5 seconds
    const interval = 100; // Check every 100 milliseconds
    let elapsedTime = 0;
    let intervalId;

    const checkElementInterval = setInterval(() => {
        const element = document.querySelector(selector);

        // If element has been found, run callback
        if (element) {
            clearInterval(intervalId);
            callback(element); // Pass the element to the callback function
        } else {
            // If over the timeout period
            if (elapsedTime >= timeout) {
                clearInterval(intervalId);
            } else {
                elapsedTime += interval;
            }
        }
    }, interval);

    intervalId = checkElementInterval;
}

function slimHeaderOnScroll(params) {

    let throttleTimeout;
    let throttleDelay = 200; // Adjust throttle delay as needed

    // Throttled scroll event handler
    function handleScroll() {
        if (!throttleTimeout) {
            throttleTimeout = setTimeout(function () {
                throttleTimeout = null;
                
                // Initial position check and class application
                scrollPosCheck(params);
            }, throttleDelay);
        }
    }

    // Track scrolling
    window.addEventListener("scroll", handleScroll.bind(this));
}

function scrollPosCheck(params) {
    const { self, siteHeader, siteHeaderSearchRef, scrollThreshold, slimHeaderStyle, slimHeaderAnimateStyle, slimHeaderNoAnimateStyle } = params;
    const headerSearchBar = siteHeader.querySelector(siteHeaderSearchRef);

    if (self.scrollY === 0) {
        // If top of page, change to standard header
        siteHeader.classList.remove(`${slimHeaderStyle}`, `${slimHeaderAnimateStyle}`, `${slimHeaderNoAnimateStyle}`);

    } // If scroll more threshold then apply slim header styles
    else if (self.scrollY > scrollThreshold) {
        siteHeader.classList.add(`${slimHeaderStyle}`);
    }

    // If search not active and below threshold, add header animation
    if (!headerSearchBar.classList.contains('header-search-bar--show') && self.scrollY > scrollThreshold) {
        siteHeader.classList.add(`${slimHeaderAnimateStyle}`);
    }
}

function searchInteractionTracking(params) {
    const { self, siteHeader, siteHeaderSearchRef, scrollThreshold, slimHeaderStyle, slimHeaderNoAnimateStyle } = params;
    const headerSearchBtnRef = 'button[data-testid="app-header-desktop-search-button"]';
    const headerSearchCloseBtnRef = 'button[data-testid="app-header-search-bar-close"]';
    const headerSearchBoxRef = 'div[data-testid="app-header-search-bar"] input#search';
    const headerSearchOverlayRef = '.sf-overlay';

    // Check for header search button
    checkForElement(headerSearchBtnRef, (searchBtnElem) => {

        // If searchBtn has never been clicked
        if (!searchBtnElem.hasAttribute('data-clicked')) {

            // Add attribute to mark that searchBtnElem has been clicked
            // This avoids the eventListener being added multiple times
            searchBtnElem.setAttribute('data-clicked', 'true');

            searchBtnElem.addEventListener('click', function () {

                // If scroll position is correct, add the class
                if (self.scrollY > scrollThreshold) {
                    siteHeader.classList.add(`${slimHeaderStyle}`, `${slimHeaderNoAnimateStyle}`);
                }

                // Check for search bar
                checkForElement(siteHeaderSearchRef, (searchBarElem) => {
                    // If a search is active...
                    if (searchBarElem.classList.contains('header-search-bar--show')) {

                        // Check for search close button
                        checkForElement(headerSearchCloseBtnRef, (searchCloseBtnElem) => {

                            // If searchCloseBtnElem has never been clicked
                            if (!searchCloseBtnElem.hasAttribute('data-clicked')) {

                                // Add attribute to mark that searchCloseBtnElem has been clicked
                                // This avoids the eventListener being added multiple times
                                searchCloseBtnElem.setAttribute('data-clicked', 'true');

                                // Listen for click on searchCloseBtnElem
                                searchCloseBtnElem.addEventListener('click', function () {

                                    // If scroll position is correct, add the class
                                    if (self.scrollY > scrollThreshold) {
                                        siteHeader.classList.add(`${slimHeaderStyle}`, `${slimHeaderNoAnimateStyle}`);
                                    }
                                });
                            }
                        });
                    }
                });

                // Check for search input box
                checkForElement(headerSearchBoxRef, (searchBoxElem) => {
                    // If searchBoxElem has never been clicked
                    if (!searchBoxElem.hasAttribute('data-clicked')) {

                        // Add attribute to mark that the event listener has been added
                        searchBoxElem.setAttribute('data-clicked', 'true');

                        let searchBoxInputCheck = false;

                        const inputCheckHandler = () => {
                            if (!searchBoxInputCheck) {
                                searchBoxInputCheck = true;

                                // Check for search overlay
                                checkForElement(headerSearchOverlayRef, (searchOverlayElem) => {

                                    // If searchBoxElem has never been clicked
                                    if (!searchOverlayElem.hasAttribute('data-clicked')) {

                                        // Add attribute to mark that searchOverlayElem has been clicked
                                        // This avoids the eventListener being added multiple times
                                        searchOverlayElem.setAttribute('data-clicked', 'true');

                                        // Listen for click on searchOverlayElem
                                        searchOverlayElem.addEventListener('click', function () {

                                            // If scroll position is correct, add the class
                                            if (self.scrollY > scrollThreshold) {
                                                siteHeader.classList.add(`${slimHeaderStyle}`, `${slimHeaderNoAnimateStyle}`);
                                            }
                                        });
                                    }

                                });
                            }

                            // Stop listen for input on searchBoxElem
                            searchBoxElem.removeEventListener('input', inputCheckHandler);
                        };

                        // Listen for input on searchBoxElem
                        searchBoxElem.addEventListener('input', inputCheckHandler);

                    }
                });

                // When a search has been started already then overlay will appear
                // Check for search overlay
                checkForElement(headerSearchOverlayRef, (searchOverlayElem) => {
                    // If searchBoxElem has never been clicked
                    if (!searchOverlayElem.hasAttribute('data-clicked')) {

                        // Add attribute to mark that searchOverlayElem has been clicked
                        // This avoids the eventListener being added multiple times
                        searchOverlayElem.setAttribute('data-clicked', 'true');

                        // Listen for click on searchOverlayElem
                        searchOverlayElem.addEventListener('click', function () {

                            // If scroll position is correct, add the class
                            if (self.scrollY > scrollThreshold) {
                                siteHeader.classList.add(`${slimHeaderStyle}`, `${slimHeaderNoAnimateStyle}`);
                            }

                        });
                    }
                });
            });
        }
    });

}

@Shoxt3r,

It would easier for people to help, if they had something to play with (html as well). Would it be possible to create a codepen with atleast a sample of what you are trying to achieve?

It does seem to me that this might be a job for an intersection observer. Using that and the right CSS I’m guessing would cut down on a great deal of the Javascript you have.

In fact it is getting to the point where CSS is able to do things like this natively. I haven’t explored the following video yet, but it might be worth a look.

Here’s a rough css version where the header gets smaller on scroll. You have to be careful with methods used or you could get a flicker if you changed height just as it became sticky.

1 Like