Javascript is required
7 min read

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.


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


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:

1export default defineNuxtConfig({
2  modules: ['@nuxt/content', '@nuxtjs/tailwindcss'],

Of course, we need some content to show the table of contents. For this demo, I will reference the file from my StackBlitz demo.

To render this content, let's create a catch-all route in the pages directory:

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>

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:

1<script setup lang="ts">
2const activeTocId = ref(null)
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>

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

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

1<script setup lang="ts">
2import { Ref } from 'vue'
4const props = withDefaults(defineProps<{ activeTocId: string }>(), {})
6const router = useRouter()
8const { data: blogPost } = await useAsyncData(`blogToc`, () => queryContent(`/`).findOne())
9const tocLinks = computed(() => blogPost.value?.body.toc.links ?? [])
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  }
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>

Let's analyze this code:

To get a list of all available headlines, we use the