Just like the title says! Here’s a sidebar navigation bar that…
- Uses sticky positioning. It stays on the screen when it can, but won’t overlap the header, footer, or ever make any of it’s links inaccessible.
- Scrolls smoothly to the sections you click to.
- Activates the current nav based on scroll position (it’s a single page thing).
See the Pen Sticky, Smooth, Active Nav by Chris Coyier (@chriscoyier) on CodePen.
Sticky
It’s easy to toss position: sticky; top: 0;
on something. But for it to work, it’s gotta be within a taller parent element. So, the unordered list (<ul>
) within the navigation (<nav>
) works great here. Thanks to the CSS grid layout, the <nav>
is as tall as the <main>
content area. However, note that that we also gotta position: -webkit-sticky;
for iOS.
I also tossed in a magic number for the vertical media query so that it doesn’t stick in such a way that you can’t get to the lower navigation items:
/* Only stick if you can fit */
@media (min-height: 300px) {
nav ul {
position: sticky;
top: 0;
}
}

Smooth
In my first crack at this, I thought about JavaScript-based smooth scrolling. It’s even native these days with no need for frameworks. You can target an element and smoothly scroll to it:
document.querySelector('.hello').scrollIntoView({
behavior: 'smooth'
});
Bringing that to an arbitrary set of nav…
let mainNavLinks = document.querySelectorAll("nav ul li a");
mainNavLinks.forEach(link => {
link.addEventListener("click", event => {
event.preventDefault();
let target = document.querySelector(event.target.hash);
target.scrollIntoView({
behavior: "smooth",
block: "start"
});
});
});
That’s supported in both Chrome and Firefox, but not Edge or Safari.
Then it occurred to me, CSS can do this! There is a scroll-behavior
property and you can put it on the document to make everything scroll that way:
html {
scroll-behavior: smooth;
}
Since our navigational <a>
links are hash/jump/anchor links, that’s literally all we need. Forget the JavaScript. Especially because the browser support for scroll-behavior
is the same as the “smooth” version of .scrollIntoView()
.
Active
This is a bit trickier, particularly because this is a single-page scrolling app rather than individual pages with their own separate documents. If they were separate documents, we’d change an active class somewhere in the navigation or use a body.specific_page
class or something.
Instead, we’ll need to look at the scroll position of the page, decide which section is in view and mark it that way. There might be some kinda fancy IntersectionObserver
way to handle this, but I couldn’t quite wrap my head around that, so instead I’m just looking at all the relevant sections, doing a little measuring and math, and deciding if the link is active that way.
let mainNavLinks = document.querySelectorAll("nav ul li a");
let mainSections = document.querySelectorAll("main section");
let lastId;
let cur = [];
window.addEventListener("scroll", event => {
let fromTop = window.scrollY;
mainNavLinks.forEach(link => {
let section = document.querySelector(link.hash);
if (
section.offsetTop <= fromTop &&
section.offsetTop + section.offsetHeight > fromTop
) {
link.classList.add("current");
} else {
link.classList.remove("current");
}
});
});
The scroll handler there should trigger a little warning flag. That’s the kind of thing that should probably be throttled, like if you have lodash available:
window.addEventListener("scroll", () => {
_.throttle(doThatStuff, 100);
});
I just didn’t do that here to keep the demo dependency-free.
Oh! And it largely works fine on mobile (iOS here):

A Free Template for JavaScript Library Homepages
I used all this stuff in this template I made that you’re free to use for whatever.

Did you consider using IntersectionObserver? (really interesting from performance point of view)
I’ve tested it for making a simple clone of RevealJS (with CSS Scroll Snap points and ScrollIntoView), see https://medium.com/@Nico3333fr/creating-a-light-revealjs-clone-with-css-scroll-snap-points-306dfba71652
I wanted to say the same! I also use IntersectionObserver in my LazyLoad and it works like a charm! Listening to the window scroll event is heavy and could just be used as a fallback.
I found a weird bug (I’m calling it a ‘bug’ because I don’t understand it) in Firefox 62dev.
causes the
nav ul
element to not be sticky. Removing that declaration from body fixes this.Ah yeah interesting! I’m not sure which browser is doing it right either, but it doesn’t seem entirely necessary so yanked it off for now and noted it in code.
For sure! That’s noted in the pen’s CSS comments. :)
Seems like this was done already in Bootstrap a long time ago with scrollspy and affix.
You might need to try a hard refresh. Seems to be working for me in latest stable (61.0.1)
I used to think using jQuery meant you’d end up with spaghetti code.
But over the last couple of years, going from angular to Vue now back to jQuery… I realised it was lack of a convention that made spaghetti code.
Your demonstration codes lack of bem and use of element chained selectors breeds a bad habit amongst burgeoning developers.
Instead:
Now you’ve decoupled js behavior from css, and modifying the case names won’t require changing the js.
Modifying the HTML structure won’t break the js.
It’s also very obvious that the element is part of something.
These are all things you’ll have to deal with as a Frontend developer working in a large team where you want to minimise the maintenance footprint for other team members.
Remember, easy and simple are two completely different outcomes.
I’ve modify the pen (quick and dirty, it has a lot of bugs) to show how could be done with intersection observer, it is not working porperly yet (at full screen it appears like is working, but if you strech the viewport it doesn’t).
On taller browser windows I’m unable to navigate to the last section.
One tweak that could help is adding
{passive: true}
to thescroll
event listener, since your handler will never cancel thescroll
event. It’s widely supported, and has performance benefits.You could employ a simple throttle function to serve the purpose without a framework/library.
Then call it with:
““
window.addEventListener(“scroll”, throttled(() => {
…
}));
“`