Yesterday evening I was working on a documentation page. The page layout is quite classic, as it consists of a content pane on the left and a sidebar navigation on the right. Looking for a way to make the page less dull I decided to add a few small things to it:
- Smooth Scrolling when clicking internal links
- A Sticky Navigation, so that the sidebar navigation always stays in view
- A “ScrollSpy” to update the active state of the navigation
These three additions make the page more delightful, and best of all is: they’re really easy to implement!
In this post I’ll lay out the details.
~
# The result
First things first, here’s a recording of the end result so that you get an idea of what I’m talking about:
~
# The Markup
The markup is really basic:
- A
main
element surrounds our contentdiv
andnav
. - Each piece of content is wrapped in a
section
which gets anid
attribute. The sidebar navigation then links to itsid
<main>
<div>
<h1>Smooth Scrolling Sticky ScrollSpy Navigation</h1>
<section id="introduction">
<h2>Introduction</h2>
<p>…</p>
</section>
<section id="request-response">
<h2>Request & Response</h2>
<p>…</p>
</section>
<section id="authentication">
<h2>Authentication</h2>
<p>…</p>
</section>
…
<section id="filters">
<h2>Filters</h2>
<p>…</p>
</section>
</div>
<nav class="section-nav">
<ol>
<li><a href="#introduction">Introduction</a></li>
<li><a href="#request-response">Request & Response</a></li>
<li><a href="#authentication">Authentication</a></li>
…
<li class=""><a href="#filters">Filters</a></li>
</ol>
</nav>
</main>
Sprinkle some CSS on top to lay everything out – using CSS Grid here – and you have a fully working – albeit dull – page:
See the Pen
Smooth Scrolling Sticky ScrollSpy Navigation (base layer) by Bramus (@bramus)
on CodePen.
~
# 1. Smooth Scrolling
Enabling smooth scrolling is really easy, it you can enable it using a single line of CSS:
html {
scroll-behavior: smooth;
}
😱 Yes, that’s it!
In the demo embedded below, click any of the links in the nav and see how smooth it scrolls:
See the Pen
Smooth Scrolling Sticky ScrollSpy Navigation (base layer) by Bramus (@bramus)
on CodePen.
For browsers that don’t support this you could add this JS fallback:
// Smooth scrolling for browsers that don't support CSS smooth scrolling
if (window.getComputedStyle(document.documentElement).scrollBehavior !== 'smooth') {
document.querySelectorAll('a[href^="#"]').forEach(internalLink => {
const targetElement = document.querySelector(internalLink.getAttribute('href'));
if (targetElement) {
internalLink.addEventListener('click', (e) => {
targetElement.scrollIntoView({
behavior: 'smooth',
});
e.preventDefault();
});
}
});
}
However, browser’s that don’t support scroll-behavior: smooth;
also don’t support behavior: "smooth"
for Element#scrollIntoView
, so there’s not real advantage to adding this JS fallback.
~
# 2. Sticky Navigation
To make the navigation stay in place as you scroll we can rely on position: sticky;
. As with Smooth Scrolling, this is a really simple CSS addition:
main > nav {
position: sticky;
top: 2rem;
align-self: start;
}
💁♂️ Since we’re using CSS Grid to lay out the children of <main>
, adding align-self: start;
to <nav>
is an important one here. If we would omit it, the nav
element would be as high as the enclosing main
element. If that were the case, then nav
would never be able to stick.
In the demo embedded below, click any of the links in the nav and see how the nav now also stays in view while the rest of the page scrolls:
See the Pen
Smooth Scrolling Sticky ScrollSpy Navigation (base layer + smooth scrolling + sticky nav) by Bramus (@bramus)
on CodePen.
~
👋 Like what you see so far? Follow @bramusblog on Twitter or subscribe to the RSS feed to stay up-to-date.
~
# 3. ScrollSpy with IntersectionObserver
Update 2021.07.19: Thanks to CSS @scroll-timeline
we can now also implement a ScrollSpy using only CSS! See my post up on CSS-Tricks to get the details.
Thanks to the almighty IntersectionObserver
we can implement a ScrollSpy. Basically we use it to watch all section["id"]
elements. If they are intersecting, we add the .active
class to any link that links to it. For styling purposes we don’t add .active
to the link itself, but to its surrounding li
element.
In code, that becomes this little snippet:
window.addEventListener('DOMContentLoaded', () => {
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
const id = entry.target.getAttribute('id');
if (entry.intersectionRatio > 0) {
document.querySelector(`nav li a[href="#${id}"]`).parentElement.classList.add('active');
} else {
document.querySelector(`nav li a[href="#${id}"]`).parentElement.classList.remove('active');
}
});
});
// Track all sections that have an `id` applied
document.querySelectorAll('section[id]').forEach((section) => {
observer.observe(section);
});
});
💡 To make the transition to and from .active
not too abrupt, add a little blob of CSS to ease things:
.section-nav a {
transition: all 100ms ease-in-out;
}
~
# Complete Demo
Putting everything together, we end up with this:
See the Pen
Smooth Scrolling Sticky ScrollSpy Navigation by Bramus (@bramus)
on CodePen.
Delightful, no? 😊
~
💡 If you’re also looking for more inspiration to make your interfaces more delightful, be sure to check Hakim El Hattab’s “Building Better Interfaces” talk. Recommended stuff!
Thank me with a coffee.
I don\'t do this for profit but a small one-time donation would surely put a smile on my face. Thanks!
To stay in the loop you can follow @bramus or follow @bramusblog on Twitter.
This could be good, except that your use of IntersectionObserver is incorrect and doesn’t trigger on smaller (vertical) viewports. Please learn to properly handle thresholds and ratios.
:-/
As I’m omitting the configuration object, it will use the default values (use the viewport as
root
, notreshold
, norootMargin
) which have the desired effect. Tests using Responsive Mode in DevTools and tests on an actual device (iPhone) verify this behavior.Always open to improve my skills, so feel free to point out where exactly things could improve, “Bob”.
:/
Hi,
Thank You for sharing this solution. It seems to be quite similar to the implementation in our project (done before reading this article to be honest ;)). I believe it’s worth mentioning though that to have this working in, for example, less modern browsers, it’s possible to simply use the pollyfill: https://www.npmjs.com/package/intersection-observer . I think it requires just importing one package, without additional changes.
Cheers!
Smooth scrolling css is not supported in safari.
https://caniuse.com/#search=scroll-behavior
The align-self: start confused me for a while, until I realized it’s inside a grid element. I was wondering what was the connection between position: sticky and align-self. 🙁
I’ve updated the post to explicitly mention that we’re using CSS Grid to lay out the contents of
main
. Thanks for bringing this to my attention.Correct. This link to Can I Use is also included in the post itself.
Great stuff!
Thanks I used this to setup a table of content for my blog! I learned new things.
Also only Firefox has built in **developer** tools for CSS grids.
here is my implementaiton of this method
https://hymma.net/articles/solidworks/weldments
An easy implementation of code
Hello Bram, when scrolling I can see that both the parent and the child element are active. How do you make only the child element to be active. For example, City detail and City config are become active at the same time, how do you make only City detail to become active. Thank you
I have the same doubt, did you find any solution to this ?
I have the same problem, did you find any solution to this ?
Thank you Barmus for this fantastic tool.
I am fairly new to the front-end development world so I may get some term mixed up. I am converting an existing html document to use your TOC but I am having issues with the main CSS below. It is pulling content that is not the section-nav and not identifying the section-nav as something to place in the right column. How does it distinguish what to pull into that right hand column?
main {
display: grid;
grid-template-columns: 1fr 15em;
max-width: 100em;
width: 90%;
margin: 0 auto;
}
Hello Bram,
Actually, it would be best if you remove the active class from the sticky list once the new active class applied to the li.
Thanks!
So this would fail if two sections are there at the root at the same time?
What would cause items to retain the active class even after leaving the viewport? That’s my current issue.
I want to use this too, so pls make an lib out of it:
– The tag can be created automaticly using the tags.
– The tags can be added automaticly while creating the
– When including the JS file to the document, it should do everything it is needed.