Javascript is required
·
7 min read
·
4013 views

Create a Table of Contents With Active States in Nuxt 3

Create a Table of Contents With Active States in Nuxt 3 Image

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:

nuxt.config.ts
1export default defineNuxtConfig({
2  modules: ['@nuxt/content', '@nuxtjs/tailwindcss'],
3})

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:

[...slug
1<template>
2  <main class="flex flex-col gap-4 p-4">
3    <ContentDoc>
4      <template #default="{ doc }">
5        <div class="grid grid-cols-12 gap-8">
6          <div class="nuxt-content col-span-8">
7            <ContentRenderer ref="nuxtContent" :value="doc" />
8          </div>
9        </div>
10      </template>
11    </ContentDoc>
12  </main>
13</template>

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:

[...slug
1<script setup lang="ts">
2const activeTocId = ref(null)
3</script>
4
5<template>
6  <main class="flex flex-col gap-4 p-4">
7    <ContentDoc>
8      <template #default="{ doc }">
9        <div class="grid grid-cols-12 gap-8">
10          <div class="nuxt-content col-span-8">
11            <ContentRenderer ref="nuxtContent" :value="doc" />
12          </div>
13          <div class="col-span-4 rounded-md border p-4">
14            <div class="sticky top-0 flex flex-col items-center">
15              <TableOfContents :activeTocId="activeTocId" />
16            </div>
17          </div>
18        </div>
19      </template>
20    </ContentDoc>
21  </main>
22</template>

I'll explain the activeTocId prop in the following "Intersection Observer" chapter.

Let's take a look at the component's code:

TableOfContents.vue
1<script setup lang="ts">
2import { Ref } from 'vue'
3
4const props = withDefaults(defineProps<{ activeTocId: string }>(), {})
5
6const router = useRouter()
7
8const { data: blogPost } = await useAsyncData(`blogToc`, () => queryContent(`/`).findOne())
9const tocLinks = computed(() => blogPost.value?.body.toc.links ?? [])
10
11const onClick = (id: string) => {
12  const el = document.getElementById(id)
13  if (el) {
14    router.push({ hash: `#${id}` })
15    el.scrollIntoView({ behavior: 'smooth', block: 'center' })
16  }
17}
18</script>
19
20<template>
21  <div class="max-h-82 overflow-auto">
22    <h4>Table of Contents</h4>
23    <nav class="mt-4 flex">
24      <ul class="ml-0 pl-4">
25        <li
26          v-for="{ id, text, children } in tocLinks"
27          :id="`toc-${id}`"
28          :key="id"
29          class="mb-2 ml-0 cursor-pointer list-none text-sm last:mb-0"
30          @click="onClick(id)"
31        >
32          {{ text }}
33          <ul v-if="children" class="my-2 ml-3">
34            <li
35              v-for="{ id: childId, text: childText } in children"
36              :id="`toc-${childId}`"
37              :key="childId"
38              class="mb-2 ml-0 cursor-pointer list-none text-xs last:mb-0"
39              @click.stop="onClick(childId)"
40            >
41              {{ childText }}
42            </li>
43          </ul>
44        </li>
45      </ul>
46    </nav>
47  </div>
48</template>

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:

1const { data: blogPost } = await useAsyncData(`blogToc`, () => queryContent(`/`).findOne())
2const tocLinks = computed(() => blogPost.value?.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:

1const onClick = (id: string) => {
2  const el = document.getElementById(id)
3  if (el) {
4    router.push({ hash: `#${id}` })
5    el.scrollIntoView({ behavior: 'smooth', block: 'center' })
6  }
7}

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:

[...slug
1<script setup lang="ts">
2import { watchDebounced } from '@vueuse/core'
3
4const activeTocId = ref(null)
5const nuxtContent = ref(null)
6
7const observer: Ref<IntersectionObserver | null | undefined> = ref(null)
8const observerOptions = reactive({
9  root: nuxtContent.value,
10  threshold: 0.5,
11})
12
13onMounted(() => {
14  observer.value = new IntersectionObserver((entries) => {
15    entries.forEach((entry) => {
16      const id = entry.target.getAttribute('id')
17      if (entry.isIntersecting) {
18        activeTocId.value = id
19      }
20    })
21  }, observerOptions)
22
23  document.querySelectorAll('.nuxt-content h2[id], .nuxt-content h3[id]').forEach((section) => {
24    observer.value?.observe(section)
25  })
26})
27
28onUnmounted(() => {
29  observer.value?.disconnect()
30})
31</script>

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 the ContentRenderer component.
  • observer is used to track the h2 and h3 HTML elements that scroll into the viewport.
  • observerOptions contains a set of options that define when the observer callback is invoked. It contains the nuxtContent 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 to 0; 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.

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:

TableOfContents.vue
1<script setup lang="ts">
2import { watchDebounced } from '@vueuse/core'
3import { Ref } from 'vue'
4
5const props = withDefaults(defineProps<{ activeTocId: string }>(), {})
6
7const router = useRouter()
8
9const sliderHeight = useState('sliderHeight', () => 0)
10const sliderTop = useState('sliderTop', () => 0)
11const tocLinksH2: Ref<Array<HTMLElement>> = ref([])
12const tocLinksH3: Ref<Array<HTMLElement>> = ref([])
13
14const { data: blogPost } = await useAsyncData(`blogToc`, () => queryContent(`/`).findOne())
15const tocLinks = computed(() => blogPost.value?.body.toc.links ?? [])
16
17const onClick = (id: string) => {
18  const el = document.getElementById(id)
19  if (el) {
20    router.push({ hash: `#${id}` })
21    el.scrollIntoView({ behavior: 'smooth', block: 'center' })
22  }
23}
24
25watchDebounced(
26  () => props.activeTocId,
27  (newActiveTocId) => {
28    const h2Link = tocLinksH2.value.find((el: HTMLElement) => el.id === `toc-${newActiveTocId}`)
29    const h3Link = tocLinksH3.value.find((el: HTMLElement) => el.id === `toc-${newActiveTocId}`)
30
31    if (h2Link) {
32      sliderHeight.value = h2Link.offsetHeight
33      sliderTop.value = h2Link.offsetTop - 100
34    } else if (h3Link) {
35      sliderHeight.value = h3Link.offsetHeight
36      sliderTop.value = h3Link.offsetTop - 100
37    }
38  },
39  { debounce: 200, immediate: true }
40)
41</script>
42
43<template>
44  <div class="max-h-82 overflow-auto">
45    <h4>Table of Contents</h4>
46    <nav class="mt-4 flex">
47      <div class="relative w-0.5 overflow-hidden rounded bg-secondary">
48        <div
49          class="
50            absolute
51            left-0
52            w-full
53            rounded
54            bg-red-500
55            transition-all
56            duration-200
57          "
58          :style="{ height: `${sliderHeight}px`, top: `${sliderTop}px` }"
59        ></div>
60      </div>
61      <ul class="ml-0 pl-4">
62        <li
63          v-for="{ id, text, children } in tocLinks"
64          :id="`toc-${id}`"
65          :key="id"
66          ref="tocLinksH2"
67          class="mb-2 ml-0 cursor-pointer list-none text-sm last:mb-0"
68          :class="{ 'font-bold': id === activeTocId }"
69          @click="onClick(id)"
70        >
71          {{ text }}
72          <ul v-if="children" class="my-2 ml-3">
73            <li
74              v-for="{ id: childId, text: childText } in children"
75              :id="`toc-${childId}`"
76              :key="childId"
77              ref="tocLinksH3"
78              class="mb-2 ml-0 cursor-pointer list-none text-xs last:mb-0"
79              :class="{ 'font-bold': childId === activeTocId }"
80              @click.stop="onClick(childId)"
81            >
82              {{ childText }}
83            </li>
84          </ul>
85        </li>
86      </ul>
87    </nav>
88  </div>
89</template>

We use the VueUse's watchDebounced composable to debounced watch changes of the active ToC element ID:

1watchDebounced(
2  () => props.activeTocId,
3  (newActiveTocId) => {
4    const h2Link = tocLinksH2.value.find((el: HTMLElement) => el.id === `toc-${newActiveTocId}`)
5    const h3Link = tocLinksH3.value.find((el: HTMLElement) => el.id === `toc-${newActiveTocId}`)
6
7    if (h2Link) {
8      sliderHeight.value = h2Link.offsetHeight
9      sliderTop.value = h2Link.offsetTop - 100
10    } else if (h3Link) {
11      sliderHeight.value = h3Link.offsetHeight
12      sliderTop.value = h3Link.offsetTop - 100
13    }
14  },
15  { debounce: 200, immediate: true }
16)

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.

I will never share any of your personal data. You can unsubscribe at any time.

If you found this article helpful.You will love these ones as well.
Focus & Code Diff in Nuxt Content Code Blocks Image

Focus & Code Diff in Nuxt Content Code Blocks

A Comprehensive Guide to Data Fetching in Nuxt 3 Image

A Comprehensive Guide to Data Fetching in Nuxt 3

Use Shiki to Style Code Blocks in HTML Emails Image

Use Shiki to Style Code Blocks in HTML Emails

How I Replaced Revue With a Custom-Built Newsletter Service Using Nuxt 3, Supabase, Serverless, and Amazon SES Image

How I Replaced Revue With a Custom-Built Newsletter Service Using Nuxt 3, Supabase, Serverless, and Amazon SES