Create a Table of Contents With Active States in Nuxt 3
I'm a big fan of a table of contents (ToC) on the side of a blog post page, especially if it is a long article. It helps me gauge the article's length and allows me to navigate between the sections quickly.
In this article, I will show you how to create a sticky table of contents sidebar with an active state based on the current scroll position using Nuxt 3, Nuxt Content and Intersection Observer.
Demo
The following StackBlitz contains the source code that is used in the following chapters:
Setup
For this demo, we need to initialize a Nuxt 3 project and install the Nuxt Content and Nuxt Tailwind (optional) modules.
We need to add these modules to nuxt.config.ts
:
Of course, we need some content to show the table of contents. For this demo, I will reference the index.md
file from my StackBlitz demo.
To render this content, let's create a catch-all route in the pages
directory:
The <ContentDoc>
component fetches and renders a single document, and the <ContentRenderer>
component renders the body of a Markdown document.
Check the official docs for more information about these Nuxt Content components.
Now let's add a TableOfContents.vue
component to this template:
I'll explain the activeTocId
prop in the following "Intersection Observer" chapter.
Let's take a look at the component's code:
Let's analyze this code:
To get a list of all available headlines, we use the queryContent composable and access them via body.toc.links
:
If someone clicks on a link in the ToC, we query the HTML element from the DOM, push the hash route and smoothly scroll the element into the viewport:
At this point, we can show a list of all the headlines of our content in the sidebar, but our ToC does not indicate which headline is currently visible.
Intersection Observer
We use the Intersection Observer to handle detecting when an element scrolls into our viewport. It's supported by almost every browser.
Nuxt Content automatically adds an id
to each heading of our content files. Using document.querySelectorAll
, we query all h2
and h3
elements associated with an id
and use the Intersection Observer API to get informed when they scroll into view.
Let's go ahead and implement that logic:
Let's break down the single steps that are happening in this code.
First, we define some reactive variables:
activeTocId
is used to track the currently active DOM element to be able to add some CSS styles to it.nuxtContent
is a Template Ref to access the DOM element of theContentRenderer
component.observer
is used to track theh2
andh3
HTML elements that scroll into the viewport.observerOptions
contains a set of options that define when the observer callback is invoked. It contains thenuxtContent
ref as root for the observer and a threshold of 0.5, which means that if 50% of the way through the viewport is visible, the callback will fire. You can also set it to0
; it will fire the callback if one element pixel is visible.
In the onMounted
lifecycle hook, we are initializing the observer. We iterate over each article heading and set the activeTocId
value if the entry intersects with the viewport. We also use document.querySelectorAll
to target our .nuxt-content
article and get the DOM elements that are either h2
or h3
elements with IDs and observe those using our previously initialized IntersectionObserver
.
Finally, we are disconnecting our observer in the onUnmounted
lifecycle hook to inform the observer to no longer track these headings when we navigate away.
Style Active Link
Let's improve the code by applying styles to the activeTocId
element in our table of contents component. It should be highlighted and show an indicator:
We use the VueUse's watchDebounced composable to debounced watch changes of the active ToC element ID:
Based on the current active ToC element ID, we find the HTML element from the list of available links and set the slider height & top values accordingly.
Check the StackBlitz demo for the full source code and to play around with this implementation. A similar ToC is also available on my blog.
Conclusion
I'm pleased with my table of contents implementation using Nuxt 3, Nuxt Content, and Intersection Observer.
Of course, you can use the Intersection Observer in a traditional Vue application without Nuxt. The Intersection Observer API is mighty and can also be used to implement features like lazy-loading images.
Leave a comment if you have a better solution to implement such a ToC.
If you liked this article, follow me on Twitter to get notified about new blog posts and more content from me.
Alternatively (or additionally), you can also subscribe to my newsletter.
Building a Polite Newsletter Popup With Nuxt 3
This year I launched a free eBook with 27 helpful tips for Vue developers for subscribers of my weekly Vue newsletter. For marketing purposes, I showed a popup on the landing page of my portfolio page each time a user visited my site. I was aware that users probably could get annoyed by that popup. Thus I added a "Don't show again" button to that popup. I thought I solved the problem!
How I Replaced Revue With a Custom-Built Newsletter Service Using Nuxt 3, Supabase, Serverless, and Amazon SES
Twitter will shut down Revue on January 18, 2023, which I previously used as a newsletter provider for Weekly Vue News.