Today, we’re going to do something fun. We’re going to create a widget that shows all the headings on a page, and lets us link to them.
Chances are, you’ve probably seen this kind of widget before. Most documentation sites use these as an intra-site navigation, and let you scroll to the correct part of the page with a quick click. And – on some particularly fancy pages – you might notice that the current section is highlighted whenever you scroll to it!
It’s definitely neat to look at – but how do you even start creating something like that yourself?
This article is going to take you through each part, so that if you ever need to make your own, you at least know where to start.
Step 1: Finding the headings
To even get started with creating this nice little navigation menu, we need to figure out what to put in it. In other words, we need to find all headings in the document. Luckily, there is a great browser feature for this:
1const headings = document.querySelectorAll("h2, h3, h4, h5, h6")
Now, we have references to all headings in the document. Note, we’re skipping the main heading – the <h1 />
tag – because we’re creating an intra-page navigation, and including the title of the document wouldn’t make too much sense.
In order to get the names of those titles, we need to loop through them all. Now, the querySelectorAll
function doesn’t return an array – but a NodeList
. It’s a good API for sure, but for our use, we need to map those values into strings. You can do that in one of two ways. One is via a loop:
1const headings = [];
2for (let heading of document.querySelectorAll("h2, h3, h4, h5, h6")) {
3 headings.push(heading.innerText);
4}
Another is through this neat little trick with turning the iterable NodeList
into a regular array and using the map
function:
1const headings = Array.from(document.querySelectorAll("h2, h3, h4, h5, h6"))
2 .map(heading => heading.textContent);
I like both. Either way – you now have a neat list of headings to show!
Step 2: Listing the headings
Now that we have the headings, let’s create a component that finds those headings and lists them out.
A first iteration might look like this:
1
2function useHeadings() {
3 const [headings, setHeadings] = React.useState([]);
4 React.useEffect(() => {
5 const elements = Array.from(document.querySelectorAll("h2, h3, h4, h5, h6"))
6 .map((element) => element.textContent);
7 setHeadings(elements);
8 }, []);
9 return headings;
10}
11
12
13function TableOfContent() {
14 const headings = useHeadings();
15 return (
16 <nav>
17 <ul>
18 {headings.map(heading => (
19 <li key={heading}>{heading}</li>
20 ))}
21 </ul>
22 </nav>
23 );
24}
Now we have a neat looking list of headings that doesn’t really do much. Let’s fix that.
Open Source Session Replay
Debugging a web application in production may be challenging and time-consuming. OpenReplay is an Open-source alternative to FullStory, LogRocket and Hotjar. It allows you to monitor and replay everything your users do and shows how your app behaves for every issue.
It’s like having your browser’s inspector open while looking over your user’s shoulder.
OpenReplay is the only open-source alternative currently available.
Happy debugging, for modern frontend teams – Start monitoring your web app for free.
Step 3: Linking the headings
A table of content isn’t much fun if you can’t click it to get to the correct section, though, so let’s make that happen. But in order to link to a particular element in an HTML document, that element needs to have an ID set.
And requiring all document headings to have IDs sounds like a tedious requirement to ask of our editors – so let’s implement that as a component.
Here’s a neat implementation of such a heading component:
1function Heading({ children, id, as: Element, ...props }) {
2 const theId = id ?? getId(children);
3 return <Element id={theId} {...props}>{children}</Element>;
4}
The getId
function turns the children of the component into a unique ID – either through a slugify function or something else.
Next, let’s add links to our <TableOfContents />
component:
1function useHeadings() {
2 const [headings, setHeadings] = React.useState([]);
3 React.useEffect(() => {
4 const elements = Array.from(document.querySelectorAll("h2, h3, h4, h5, h6"))
5 .filter((element) => element.id)
6 .map((element) => ({
7 id: element.id,
8 text: element.textContent ?? "",
9 }));
10 setHeadings(elements);
11 }, []);
12 return headings;
13}
14
15function TableOfContent() {
16 const headings = useHeadings();
17 return (
18 <nav>
19 <ul>
20 {headings.map(heading => (
21 <li key={heading.id}>
22 <a href={`#${heading.id}`}>
23 {heading.text}
24 </a>
25 </li>
26 ))}
27 </ul>
28 </nav>
29 );
30}
We did two things here – we changed the getHeadings
function to return both the ID and text of each heading as an object, and we added a link to each list item.
And with that, we have a linked table of content! 💪
Step 4: Creating a visual hierarchy
One thing that irks me still though, is not having any idea of what’s a main heading and what’s a sub heading. So let’s fix that.
First, we need to get the heading level info from the getHeading
function:
1function useHeadings() {
2 const [headings, setHeadings] = React.useState([]);
3 React.useEffect(() => {
4 const elements = Array.from(document.querySelectorAll("h2, h3, h4, h5, h6"))
5 .filter((element) => element.id)
6 .map((element) => ({
7 id: element.id,
8 text: element.textContent ?? "",
9 level: Number(element.tagName.substring(1))
10 }));
11 setHeadings(elements);
12 }, []);
13 return headings;
14}
Here, we find the tagName
, remove the leading h
and turn the remaining string into a number,
Next, let’s add some styles to make this visual hierarchy as well!
1function TableOfContent() {
2 const headings = useHeadings();
3 return (
4 <nav>
5 <ul>
6 {headings.map(heading => (
7 <li
8 key={heading.id}
9 style={{ marginLeft: `${heading.level - 2}em` }}
10 >
11 <a href={`#${heading.id}`}>
12 {heading.text}
13 </a>
14 </li>
15 )}
16 </ul>
17 </nav>
18 );
19}
Boom – now it looks neat as well!
Well, most of these widgets tend to get stuck up in the top right corner. So if you want to get that effect – let’s add a few more lines of styles:
1function TableOfContent() {
2 const headings = useHeadings();
3 return (
4 <nav style={{ position: 'fixed', top: '1em', right: '1em' }}>
5 {...}
6 </nav>
7 );
8}
Now this really looks the part!
Step 5: Mark the active section as active
Finally, let’s add a pretty neat feature – highlighting the currently visible heading!
To do this, we need to write a custom hook that tells us what element is in view at a given time. Here’s an implementation of just that:
1export function useScrollSpy(
2 ids,
3 options
4) {
5 const [activeId, setActiveId] = React.useState();
6 const observer = React.useRef();
7 React.useEffect(() => {
8 const elements = ids.map((id) =>
9 document.getElementById(id)
10 );
11 observer.current?.disconnect();
12 observer.current = new IntersectionObserver((entries) => {
13 entries.forEach((entry) => {
14 if (entry?.isIntersecting) {
15 setActiveId(entry.target.id);
16 }
17 });
18 }, options);
19 elements.forEach((el) => {
20 if (el) {
21 observer.current?.observe(el);
22 }
23 });
24 return () => observer.current?.disconnect();
25 }, [ids, options]);
26 return activeId;
27}
If this makes no sense to you – that’s totally fine. Give the MDN docs a quick review. But you pass it a list of IDs, and it returns the active ID at any given time.
Let’s use this in our <TableOfContent />
component:
1const activeId = useScrollSpy(
2 headings.map(({ id }) => id),
3 { rootMargin: "0% 0% -25% 0%" }
4);
Here, we specify that whenever a particular heading is scrolled a quarter of the way into the page, we mark it as active. Here’s how that would look:
1function TableOfContent() {
2 const headings = useHeadings();
3 const activeId = useScrollSpy(
4 headings.map(({ id }) => id),
5 { rootMargin: "0% 0% -25% 0%" }
6 );
7 return (
8 <nav style={{ position: 'fixed', top: '1em', right: '1em' }}>
9 <ul>
10 {headings.map(heading => (
11 <li key={heading.id} style={{ marginLeft: `${heading.level}em` }}>
12 <a
13 href={`#${heading.id}`}
14 style={{
15 fontWeight: activeId === heading.id ? "bold" : "normal"
16 }}
17 >
18 {heading}
19 </a>
20 </li>
21 )}
22 </ul>
23 </nav>
24 );
25}
And with that – we’re done!
We’ve created an auto-generating table of content component that links to any heading with an ID, and shows us which heading is in view at any given time.
Here’s a working demo as well, if you want to test it out: https://codesandbox.io/s/infallible-borg-mqh9df?file=/src/App.tsx
I hope you learned a few techniques by following along with this article, and that you find it right when you need to make this kind of component yourself. Thanks for reading!
More Stories like this
GitHub – droppyjs/droppy: Self-hosted file storage
Jest VSCode Extension | How to code Tutorial.
Import SVGs as React Components | How to code Tutorial