Highlight Menu Item when Scrolling Down to Section
Asked Answered
D

5

23

I know this question have been asked a million times on this forum, but none of the articles helped me reach a solution.

I made a little piece of jquery code that highlights the hash-link when you scroll down to the section with the same id as in the hash-link.

$(window).scroll(function() {
    var position = $(this).scrollTop();

    $('.section').each(function() {
        var target = $(this).offset().top;
        var id = $(this).attr('id');

        if (position >= target) {
            $('#navigation > ul > li > a').attr('href', id).addClass('active');
        }
    });
});

The problem now is that it highlights all of the hash-links instead of just the one that the section has a relation to. Can anyone point out the mistake, or is it something that I forgot?

Donau answered 4/9, 2015 at 10:37 Comment(0)
C
56

EDIT:

I have modified my answer to talk a little about performance and some particular cases.

If you are here just looking for code, there is a commented snippet at the bottom.


Original answer

Instead of adding the .active class to all the links, you should identify the one which attribute href is the same as the section's id.

Then you can add the .active class to that link and remove it from the rest.

        if (position >= target) {
            $('#navigation > ul > li > a').removeClass('active');
            $('#navigation > ul > li > a[href=#' + id + ']').addClass('active');
        }

With the above modification your code will correctly highlight the corresponding link. Hope it helps!


Improving performance

Even when this code will do its job, is far from being optimal. Anyway, remember:

We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%. (Donald Knuth)

So if, event testing in a slow device, you experience no performance issues, the best you can do is to stop reading and to think about the next amazing feature for your project!

There are, basically, three steps to improve the performance:

Make as much previous work as possible:

In order to avoid searching the DOM once and again (each time the event is triggered), you can cache your jQuery objects beforehand (e.g. on document.ready):

var $navigationLinks = $('#navigation > ul > li > a');
var $sections = $(".section"); 

Then, you can map each section to the corresponding navigation link:

var sectionIdTonavigationLink = {};
$sections.each( function(){
    sectionIdTonavigationLink[ $(this).attr('id') ] = $('#navigation > ul > li > a[href=\\#' + $(this).attr('id') + ']');
});

Note the two backslashes in the anchor selector: the hash '#' has a special meaning in CSS so it must be escaped (thanks @Johnnie).

Also, you could cache the position of each section (Bootstrap's Scrollspy does it). But, if you do it, you need to remember to update them every time they change (the user resizes the window, new content is added via ajax, a subsection is expanded, etc).

Optimize the event handler:

Imagine that the user is scrolling inside one section: the active navigation link doesn't need to change. But if you look at the code above you will see that actually it changes several times. Before the correct link get highlighted, all the previous links will do it as well (because their corresponding sections also validate the condition position >= target).

One solution is to iterate the sections for the bottom to the top, the first one whose .offset().top is equal or smaller than $(window).scrollTop is the correct one. And yes, you can rely on jQuery returning the objects in the order of the DOM (since version 1.3.2). To iterate from bottom to top just select them in inverse order:

var $sections = $( $(".section").get().reverse() );
$sections.each( ... );

The double $() is necessary because get() returns DOM elements, not jQuery objects.

Once you have found the correct section, you should return false to exit the loop and avoid to check further sections.

Finally, you shouldn't do anything if the correct navigation link is already highlighted, so check it out:

if ( !$navigationLink.hasClass( 'active' ) ) {
    $navigationLinks.removeClass('active');
    $navigationLink.addClass('active');
}

Trigger the event as less as possible:

The most definitive way to prevent high-rated events (scroll, resize...) from making your site slow or unresponsive is to control how often the event handler is called: sure you don't need to check which link needs to be highlighted 100 times per second! If, besides the link highlighting, you add some fancy parallax effect you can ran fast intro troubles.

At this point, sure you want to read about throttle, debounce and requestAnimationFrame. This article is a nice lecture and give you a very good overview about three of them. For our case, throttling fits best our needs.

Basically, throttling enforces a minimum time interval between two function executions.

I have implemented a throttle function in the snippet. From there you can get more sophisticated, or even better, use a library like underscore.js or lodash (if you don't need the whole library you can always extract from there the throttle function).

Note: if you look around, you will find more simple throttle functions. Beware of them because they can miss the last event trigger (and that is the most important one!).

Particular cases:

I will not include these cases in the snippet, to not complicate it any further.

In the snippet below, the links will get highlighted when the section reaches the very top of the page. If you want them highlighted before, you can add a small offset in this way:

if (position + offset >= target) {

This is particullary useful when you have a top navigation bar.

And if your last section is too small to reach the top of the page, you can hightlight its corresponding link when the scrollbar is in its bottom-most position:

if ( $(window).scrollTop() >= $(document).height() - $(window).height() ) {
    // highlight the last link

There are some browser support issues thought. You can read more about it here and here.

Snippet and test

Finally, here you have a commented snippet. Please note that I have changed the name of some variables to make them more descriptive.

// cache the navigation links 
var $navigationLinks = $('#navigation > ul > li > a');
// cache (in reversed order) the sections
var $sections = $($(".section").get().reverse());

// map each section id to their corresponding navigation link
var sectionIdTonavigationLink = {};
$sections.each(function() {
    var id = $(this).attr('id');
    sectionIdTonavigationLink[id] = $('#navigation > ul > li > a[href=\\#' + id + ']');
});

// throttle function, enforces a minimum time interval
function throttle(fn, interval) {
    var lastCall, timeoutId;
    return function () {
        var now = new Date().getTime();
        if (lastCall && now < (lastCall + interval) ) {
            // if we are inside the interval we wait
            clearTimeout(timeoutId);
            timeoutId = setTimeout(function () {
                lastCall = now;
                fn.call();
            }, interval - (now - lastCall) );
        } else {
            // otherwise, we directly call the function 
            lastCall = now;
            fn.call();
        }
    };
}

function highlightNavigation() {
    // get the current vertical position of the scroll bar
    var scrollPosition = $(window).scrollTop();

    // iterate the sections
    $sections.each(function() {
        var currentSection = $(this);
        // get the position of the section
        var sectionTop = currentSection.offset().top;

        // if the user has scrolled over the top of the section  
        if (scrollPosition >= sectionTop) {
            // get the section id
            var id = currentSection.attr('id');
            // get the corresponding navigation link
            var $navigationLink = sectionIdTonavigationLink[id];
            // if the link is not active
            if (!$navigationLink.hasClass('active')) {
                // remove .active class from all the links
                $navigationLinks.removeClass('active');
                // add .active class to the current link
                $navigationLink.addClass('active');
            }
            // we have found our section, so we return false to exit the each loop
            return false;
        }
    });
}

$(window).scroll( throttle(highlightNavigation,100) );

// if you don't want to throttle the function use this instead:
// $(window).scroll( highlightNavigation );
#navigation {
    position: fixed;
}
#sections {
    position: absolute;
    left: 150px;
}
.section {
    height: 200px;
    margin: 10px;
    padding: 10px;
    border: 1px dashed black;
}
#section5 {
    height: 1000px;
}
.active {
    background: red;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<div id="navigation">
    <ul>
        <li><a href="#section1">Section 1</a></li>
        <li><a href="#section2">Section 2</a></li>
        <li><a href="#section3">Section 3</a></li>
        <li><a href="#section4">Section 4</a></li>
        <li><a href="#section5">Section 5</a></li>
    </ul>
</div>
<div id="sections">
    <div id="section1" class="section">
        I'm section 1
    </div>
    <div id="section2" class="section">
        I'm section 2
    </div>
    <div id="section3" class="section">
        I'm section 3
    </div>
    <div id="section4" class="section">
        I'm section 4
    </div>
    <div id="section5" class="section">
        I'm section 5
    </div>
</div>

And in case you are interested, this fiddle tests the different improvements we have talked about.

Happy coding!

Calley answered 4/9, 2015 at 11:4 Comment(10)
Why does the addClass work for me and the removeClass not?Mute
@FrankLucas It's hard to say out of a context... I advise you to open a new question providing more info.Calley
@David. I tried the snipped but the last section does not highlight.Felipe
@WosleyAlarico: That is just because I made the sections small and the last one never reached the top of the page :) I have modified the CSS to make the last section higher.Calley
@David.It makes sense with regards to giving a height to the section. But now if you look on this site which am currently giving the final touches: www.scentology.burnnotice.co.za. The section is quite big,but still the last menu item does not get highlighted. just to enphasize,I did not give set a specific height to each section. Appreciate the support in advanceFelipe
@WosleyAlarico: I saw that you opened a new question, lets continue there.Calley
@Calley can you explain this line to me ? What does those plus sing means ? $('#navigation > ul > li > a[href=#' + id + ']').addClass('active');Morula
@Calley why this line returns undefined? sectionIdTonavigationLink[id] = '#navigation > ul > li > a [href=\\#' + id + ']'.classList.add('active') ;Morula
@williamwilliams - In this context, + is the concatenation operator. The line returns undefined if $('#navigation > ul > li > a[href=#' + id + ']') does not match any element. That means, you don't have any link in your navigation bar with a href attribute equal to #your_id.Calley
@Calley i was trying to modify your code rewrite in vanilla js but i don't know why i got error when i try to check and or assign class Active to this line sectionIdTonavigationLink[sectionId] = '#navigation > ul > li > a [href=\\#' + sectionId + ']';Morula
F
7

I've taken David's excellent code and removed all jQuery dependencies from it, in case anyone's interested:

// cache the navigation links 
var $navigationLinks = document.querySelectorAll('nav > ul > li > a');
// cache (in reversed order) the sections
var $sections = document.getElementsByTagName('section');

// map each section id to their corresponding navigation link
var sectionIdTonavigationLink = {};
for (var i = $sections.length-1; i >= 0; i--) {
	var id = $sections[i].id;
	sectionIdTonavigationLink[id] = document.querySelectorAll('nav > ul > li > a[href=\\#' + id + ']') || null;
}

// throttle function, enforces a minimum time interval
function throttle(fn, interval) {
	var lastCall, timeoutId;
	return function () {
		var now = new Date().getTime();
		if (lastCall && now < (lastCall + interval) ) {
			// if we are inside the interval we wait
			clearTimeout(timeoutId);
			timeoutId = setTimeout(function () {
				lastCall = now;
				fn.call();
			}, interval - (now - lastCall) );
		} else {
			// otherwise, we directly call the function 
			lastCall = now;
			fn.call();
		}
	};
}

function getOffset( el ) {
	var _x = 0;
	var _y = 0;
	while( el && !isNaN( el.offsetLeft ) && !isNaN( el.offsetTop ) ) {
		_x += el.offsetLeft - el.scrollLeft;
		_y += el.offsetTop - el.scrollTop;
		el = el.offsetParent;
	}
	return { top: _y, left: _x };
}

function highlightNavigation() {
	// get the current vertical position of the scroll bar
	var scrollPosition = window.pageYOffset || document.documentElement.scrollTop;

	// iterate the sections
	for (var i = $sections.length-1; i >= 0; i--) {
		var currentSection = $sections[i];
		// get the position of the section
		var sectionTop = getOffset(currentSection).top;

	   // if the user has scrolled over the top of the section  
		if (scrollPosition >= sectionTop - 250) {
			// get the section id
			var id = currentSection.id;
			// get the corresponding navigation link
			var $navigationLink = sectionIdTonavigationLink[id];
			// if the link is not active
			if (typeof $navigationLink[0] !== 'undefined') {
				if (!$navigationLink[0].classList.contains('active')) {
					// remove .active class from all the links
					for (i = 0; i < $navigationLinks.length; i++) {
						$navigationLinks[i].className = $navigationLinks[i].className.replace(/ active/, '');
					}
					// add .active class to the current link
					$navigationLink[0].className += (' active');
				}
			} else {
					// remove .active class from all the links
					for (i = 0; i < $navigationLinks.length; i++) {
						$navigationLinks[i].className = $navigationLinks[i].className.replace(/ active/, '');
					}
			}	
			// we have found our section, so we return false to exit the each loop
			return false;
		}
	}
}

window.addEventListener('scroll',throttle(highlightNavigation,150));
Frontogenesis answered 14/8, 2019 at 12:45 Comment(3)
Please also explain how your code helps the OP's problem. :)Verney
The presence of a $ prefix on the variable names, e.g. $navigationLinks, usually indicates it is specifically a jQuery variable, https://mcmap.net/q/45621/-why-would-a-javascript-variable-start-with-a-dollar-sign-duplicate, so the $ should be removed if this answer wants to be truly jQuery-free.Bisutun
@Frontogenesis , why you iterate section in reverse ? Is there any importance of doing like that rather than iterating normalMorula
O
5

For anyone trying to use this solution more recently, I hit a snag trying to get it to work. You may need to escape the href like so:

$('#navigation > ul > li > a[href=\\#' + id + ']');

And now my browser doesn't throw an error on that piece.

Oireachtas answered 3/10, 2017 at 21:53 Comment(2)
I had to change mine to $('#navigation > ul > li > a[href$=' + id + ']'); via this answer: https://mcmap.net/q/63503/-select-lt-a-gt-which-href-ends-with-some-stringEstep
spent the better part of a day trying to get this to work and found this solution, thanks!Casaba
C
1
function navHighlight() {
    var scrollTop = $(document).scrollTop();

    $("section").each(function () {
        var xPos = $(this).position();
        var sectionPos = xPos.top;
        var sectionHeight = $(this).height();
        var overall = scrollTop + sectionHeight;

        if ((scrollTop + 20) >= sectionPos && scrollTop < overall) {
            $(this).addClass("SectionActive");
            $(this).prevAll().removeClass("SectionActive");
        }

        else if (scrollTop <= overall) {
            $(this).removeClass("SectionActive");
        }

        var xIndex = $(".SectionActive").index();
        var accIndex = xIndex + 1;

        $("nav li:nth-child(" + accIndex + ")").addClass("navActivePage").siblings().removeClass("navActivePage");
    });
}


.navActivePage {
    color: #fdc166;
}


$(document).scroll(function () {
    navHighlight();
});
Counterinsurgency answered 9/12, 2019 at 15:46 Comment(2)
While this code may answer the question, providing additional context regarding why and/or how this code answers the question improves its long-term value.Aniseed
Thanks. After trying a bunch of code finally, this one works perfectly.Kemper
M
0

In this line:

 $('#navigation > ul > li > a').attr('href', id).addClass('active');

You are actually setting the href attribute of every $('#navigation > ul > li > a') element, and then adding the active class also to all of them. May be what you need to do is something like:

$('#navigation > ul > li > a[href=#' + id + ']')

And select only the a which href match the id. Make sense?

Micco answered 4/9, 2015 at 10:50 Comment(1)
Thank you, I will try it out! :-)Donau

© 2022 - 2024 — McMap. All rights reserved.