Create a Table of Contents With Active States in Nuxt 3
Tags: nuxt, vue
Published: 2022-12-08 07:50:58

---
title: Create a Table of Contents With Active States in Nuxt 3
published: true
date: 2022-12-07 00:00:00 UTC
tags: nuxt, nuxtjs, vue, vuejs
canonical_url: https://mokkapps.de/blog/create-a-table-of-contents-with-active-states-in-nuxt-3
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yp5tj4cmndlq0ru3tk3l.jpg
---

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](https://nuxt.com/), [Nuxt Content](https://nuxt.com/modules/content) and [Intersection Observer](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API).

## Demo

The following StackBlitz contains the source code that is used in the following chapters:

{% embed https://stackblitz.com/edit/nuxt-content-table-of-contents-demo?embed=1 %}

## Setup

For this demo, we need to [initialize a Nuxt 3](https://nuxt.com/docs/getting-started/installation) project and install the [Nuxt Content](https://content.nuxtjs.org/get-started) and [Nuxt Tailwind](https://tailwindcss.nuxt.dev/getting-started/setup) (optional) modules.

We need to add these modules to `nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  modules: ['@nuxt/content', '@nuxtjs/tailwindcss'],
})
```

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](https://stackblitz.com/edit/nuxt-content-table-of-contents-demo?file=content/index.md).

To render this content, let's create a [catch-all route](https://nuxt.com/docs/guide/directory-structure/pages#catch-all-route) in the `pages` directory:

```vue
<template>
  <main class="p-4 flex flex-col gap-4">
    <ContentDoc>
      <template #default="{ doc }">
        <div class="grid grid-cols-12 gap-8">
          <div class="nuxt-content col-span-8">
            <ContentRenderer ref="nuxtContent" :value="doc" />
          </div>
        </div>
      </template>
    </ContentDoc>
  </main>
</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](https://content.nuxtjs.org/api/components/content-renderer) for more information about these Nuxt Content components.

Now let's add a `TableOfContents.vue` component to this template:

```vue
<script setup lang="ts">
const activeTocId = ref(null)
</script>

<template>
  <main class="p-4 flex flex-col gap-4">
    <ContentDoc>
      <template #default="{ doc }">
        <div class="grid grid-cols-12 gap-8">
          <div class="nuxt-content col-span-8">
            <ContentRenderer ref="nuxtContent" :value="doc" />
          </div>
          <div class="col-span-4 border rounded-md p-4">
            <div class="sticky top-0 flex flex-col items-center">
              <TableOfContents :activeTocId="activeTocId" />
            </div>
          </div>
        </div>
      </template>
    </ContentDoc>
  </main>
</template>
```

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

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

```vue
<script setup lang="ts">
import { Ref } from 'vue'

const props = withDefaults(defineProps<{ activeTocId: string }>(), {})

const router = useRouter()

const { data: blogPost } = await useAsyncData(`blogToc`, () => queryContent(`/`).findOne())
const tocLinks = computed(() => blogPost.value?.body.toc.links ?? [])

const onClick = (id: string) => {
  const el = document.getElementById(id)
  if (el) {
    router.push({ hash: `#${id}` })
    el.scrollIntoView({ behavior: 'smooth', block: 'center' })
  }
}
</script>

<template>
  <div class="max-h-82 overflow-auto">
    <h4>Table of Contents</h4>
    <nav class="flex mt-4">
      <ul class="ml-0 pl-4">
        <li
          v-for="{ id, text, children } in tocLinks"
          :id="`toc-${id}`"
          :key="id"
          class="cursor-pointer text-sm list-none ml-0 mb-2 last:mb-0"
          @click="onClick(id)"
        >
          {{ text }}
          <ul v-if="children" class="ml-3 my-2">
            <li
              v-for="{ id: childId, text: childText } in children"
              :id="`toc-${childId}`"
              :key="childId"
              class="cursor-pointer text-xs list-none ml-0 mb-2 last:mb-0"
              @click.stop="onClick(childId)"
            >
              {{ childText }}
            </li>
          </ul>
        </li>
      </ul>
    </nav>
  </div>
</template>
```

Let's analyze this code:

To get a list of all available headlines, we use the [queryContent composable](https://content.nuxtjs.org/api/composables/query-content) and access them via `body.toc.links`:

```ts
const { data: blogPost } = await useAsyncData(`blogToc`, () => queryContent(`/`).findOne())
const 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:

```ts
const onClick = (id: string) => {
  const el = document.getElementById(id)
  if (el) {
    router.push({ hash: `#${id}` })
    el.scrollIntoView({ behavior: 'smooth', block: 'center' })
  }
}
```

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](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) to handle detecting when an element scrolls into our viewport. It's [supported by almost every browser](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#browser_compatibility).

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:

```vue
<script setup lang="ts">
import { watchDebounced } from '@vueuse/core'

const activeTocId = ref(null)
const nuxtContent = ref(null)

const observer: Ref<IntersectionObserver | null | undefined> = ref(null)
const observerOptions = reactive({
  root: nuxtContent.value,
  threshold: 0.5,
})

onMounted(() => {
  observer.value = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      const id = entry.target.getAttribute('id')
      if (entry.isIntersecting) {
        activeTocId.value = id
      }
    })
  }, observerOptions)

  document.querySelectorAll('.nuxt-content h2[id], .nuxt-content h3[id]').forEach((section) => {
    observer.value?.observe(section)
  })
})

onUnmounted(() => {
  observer.value?.disconnect()
})
</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](https://vuejs.org/guide/essentials/template-refs.html#template-refs) 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.

## 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:

```vue
<script setup lang="ts">
import { watchDebounced } from '@vueuse/core'
import { Ref } from 'vue'

const props = withDefaults(defineProps<{ activeTocId: string }>(), {})

const router = useRouter()

const sliderHeight = useState('sliderHeight', () => 0)
const sliderTop = useState('sliderTop', () => 0)
const tocLinksH2: Ref<Array<HTMLElement>> = ref([])
const tocLinksH3: Ref<Array<HTMLElement>> = ref([])

const { data: blogPost } = await useAsyncData(`blogToc`, () => queryContent(`/`).findOne())
const tocLinks = computed(() => blogPost.value?.body.toc.links ?? [])

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

watchDebounced(
  () => props.activeTocId,
  (newActiveTocId) => {
    const h2Link = tocLinksH2.value.find((el: HTMLElement) => el.id === `toc-${newActiveTocId}`)
    const h3Link = tocLinksH3.value.find((el: HTMLElement) => el.id === `toc-${newActiveTocId}`)

    if (h2Link) {
      sliderHeight.value = h2Link.offsetHeight
      sliderTop.value = h2Link.offsetTop - 100
    } else if (h3Link) {
      sliderHeight.value = h3Link.offsetHeight
      sliderTop.value = h3Link.offsetTop - 100
    }
  },
  { debounce: 200, immediate: true }
)
</script>

<template>
  <div class="max-h-82 overflow-auto">
    <h4>Table of Contents</h4>
    <nav class="flex mt-4">
      <div class="relative bg-secondary w-0.5 overflow-hidden rounded">
        <div
          class="
            absolute
            left-0
            w-full
            transition-all
            duration-200
            rounded
            bg-red-500
          "
          :style="{ height: `${sliderHeight}px`, top: `${sliderTop}px` }"
        ></div>
      </div>
      <ul class="ml-0 pl-4">
        <li
          v-for="{ id, text, children } in tocLinks"
          :id="`toc-${id}`"
          :key="id"
          ref="tocLinksH2"
          class="cursor-pointer text-sm list-none ml-0 mb-2 last:mb-0"
          :class="{ 'font-bold': id === activeTocId }"
          @click="onClick(id)"
        >
          {{ text }}
          <ul v-if="children" class="ml-3 my-2">
            <li
              v-for="{ id: childId, text: childText } in children"
              :id="`toc-${childId}`"
              :key="childId"
              ref="tocLinksH3"
              class="cursor-pointer text-xs list-none ml-0 mb-2 last:mb-0"
              :class="{ 'font-bold': childId === activeTocId }"
              @click.stop="onClick(childId)"
            >
              {{ childText }}
            </li>
          </ul>
        </li>
      </ul>
    </nav>
  </div>
</template>
```

We use the [VueUse's watchDebounced composable](https://vueuse.org/shared/watchdebounced/#watchdebounced) to debounced watch changes of the active ToC element ID:

```ts
watchDebounced(
  () => props.activeTocId,
  (newActiveTocId) => {
    const h2Link = tocLinksH2.value.find((el: HTMLElement) => el.id === `toc-${newActiveTocId}`)
    const h3Link = tocLinksH3.value.find((el: HTMLElement) => el.id === `toc-${newActiveTocId}`)

    if (h2Link) {
      sliderHeight.value = h2Link.offsetHeight
      sliderTop.value = h2Link.offsetTop - 100
    } else if (h3Link) {
      sliderHeight.value = h3Link.offsetHeight
      sliderTop.value = h3Link.offsetTop - 100
    }
  },
  { debounce: 200, immediate: true }
)
```

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](https://mokkapps.de/blog/create-a-table-of-contents-with-active-states-in-nuxt-3#demo) for the full source code and to play around with this implementation. A similar ToC is also available on my [blog](https://mokkapps.de/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](https://www.webtips.dev/how-to-lazy-load-images-with-intersection-observer).

Leave a comment if you have a better solution to implement such a ToC.

If you liked this article, follow me on [Twitter](https://twitter.com/mokkapps) to get notified about new blog posts and more content from me.

Alternatively (or additionally), you can also [subscribe to my newsletter](https://mokkapps.de/newsletter).