ContentToc

A sticky Table of Contents with automatic active anchor link highlighting.
This component is only available when the @nuxt/content module is installed.

Usage

Use the links prop with the page?.body?.toc?.links you get when fetching a page.

<script setup lang="ts">
const route = useRoute()

const { data: page } = await useAsyncData(route.path, () => queryCollection('content').path(route.path).first())
if (!page.value) {
  throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
</script>

<template>
  <UContentToc :links="page?.body?.toc?.links" />
</template>

Title

Use the title prop to change the title of the Table of Contents.

<script setup lang="ts">
const links = ref([
  {
    id: 'usage',
    depth: 2,
    text: 'Usage',
    children: [
      {
        id: 'title',
        depth: 3,
        text: 'Title'
      },
      {
        id: 'color',
        depth: 3,
        text: 'Color'
      },
      {
        id: 'highlight',
        depth: 3,
        text: 'Highlight'
      }
    ]
  },
  {
    id: 'api',
    depth: 2,
    text: 'API',
    children: [
      {
        id: 'props',
        depth: 3,
        text: 'Props'
      },
      {
        id: 'slots',
        depth: 3,
        text: 'Slots'
      }
    ]
  },
  {
    id: 'theme',
    depth: 2,
    text: 'Theme'
  }
])
</script>

<template>
  <UContentToc title="On this page" :links="links" />
</template>

Color

Use the color prop to change the color of the links.

<script setup lang="ts">
const links = ref([
  {
    id: 'usage',
    depth: 2,
    text: 'Usage',
    children: [
      {
        id: 'title',
        depth: 3,
        text: 'Title'
      },
      {
        id: 'color',
        depth: 3,
        text: 'Color'
      },
      {
        id: 'highlight',
        depth: 3,
        text: 'Highlight'
      }
    ]
  }
])
</script>

<template>
  <UContentToc color="neutral" :links="links" />
</template>

Highlight

Use the highlight prop to display a highlighted border for the active item.

Use the highlight-color prop to change the color of the border. It defaults to the color prop.

<script setup lang="ts">
const links = ref([
  {
    id: 'usage',
    depth: 2,
    text: 'Usage',
    children: [
      {
        id: 'title',
        depth: 3,
        text: 'Title'
      },
      {
        id: 'color',
        depth: 3,
        text: 'Color'
      },
      {
        id: 'highlight',
        depth: 3,
        text: 'Highlight'
      }
    ]
  }
])
</script>

<template>
  <UContentToc highlight highlight-color="neutral" color="neutral" :links="links" />
</template>

Examples

Within a page

Use the ContentToc component in a page to display the Table of Contents:

pages/[...slug].vue
<script setup lang="ts">
const route = useRoute()

const { data: page } = await useAsyncData(route.path, () => queryCollection('content').path(route.path).first())
if (!page.value) {
  throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
</script>

<template>
  <UPage v-if="page">
    <UPageHeader :title="page.title" />

    <UPageBody>
      <ContentRenderer v-if="page.body" :value="page" />

      <USeparator v-if="surround?.filter(Boolean).length" />

      <UContentSurround :surround="(surround as any)" />
    </UPageBody>

    <template v-if="page?.body?.toc?.links?.length" #right>
      <UContentToc :links="page.body.toc.links" />
    </template>
  </UPage>
</template>

API

Props

Prop Default Type
as

'nav'

any

The element or component this component should render as.

trailingIcon

appConfig.ui.icons.chevronDown

string

The icon displayed to collapse the content.

title

'On this page'

string

The title of the table of contents.

color

'primary'

"error" | "primary" | "secondary" | "success" | "info" | "warning" | "neutral"

highlight

false

boolean

Display a line next to the active link.

highlightColor

'primary'

"error" | "primary" | "secondary" | "success" | "info" | "warning" | "neutral"

links

ContentTocLink[]

defaultOpen

boolean

The open state of the collapsible when it is initially rendered.
Use when you do not need to control its open state.

open

boolean

The controlled open state of the collapsible. Can be binded with v-model.

ui

Partial<{ root: string; container: string; top: string; bottom: string; trigger: string; title: string; trailing: string; trailingIcon: string; content: string; list: string; listWithChildren: string; item: string; itemWithChildren: string; link: string; indicator: string; }>

Slots

Slot Type
leading

{ open: boolean; }

default

{ open: boolean; }

trailing

{ open: boolean; }

content

{ links: ContentTocLink[]; }

link

{ link: ContentTocLink; }

top

{ links?: ContentTocLink[] | undefined; }

bottom

{ links?: ContentTocLink[] | undefined; }

Emits

Event Type
update:open

[value: boolean]

move

[id: string]

Theme

app.config.ts
export default defineAppConfig({
  uiPro: {
    contentToc: {
      slots: {
        root: 'sticky top-[calc(var(--ui-header-height)+1px)] bg-(--ui-bg)/75 lg:bg-[initial] backdrop-blur -mx-4 px-4 sm:px-6 sm:-mx-6 overflow-y-auto max-h-[calc(100vh-var(--ui-header-height))]',
        container: 'pt-4 sm:pt-6 pb-2.5 sm:pb-4.5 lg:py-8 border-b border-dashed border-(--ui-border) lg:border-0 flex flex-col',
        top: '',
        bottom: 'mt-6 hidden lg:flex lg:flex-col gap-6',
        trigger: 'group text-sm font-semibold flex-1 flex items-center gap-1.5 py-1.5 -mt-1.5 focus-visible:outline-(--ui-primary)',
        title: 'truncate',
        trailing: 'ms-auto inline-flex gap-1.5 items-center',
        trailingIcon: 'size-5 transform transition-transform duration-200 shrink-0 group-data-[state=open]:rotate-180 lg:hidden',
        content: 'data-[state=open]:animate-[collapsible-down_200ms_ease-out] data-[state=closed]:animate-[collapsible-up_200ms_ease-out] overflow-hidden focus:outline-none',
        list: '',
        listWithChildren: 'ms-3',
        item: '',
        itemWithChildren: '',
        link: 'group text-sm block truncate focus-visible:outline-(--ui-primary) py-1',
        indicator: 'absolute ms-2.5 transition-[translate,height] duration-200 h-(--indicator-size) translate-y-(--indicator-position) w-px rounded-full'
      },
      variants: {
        color: {
          primary: '',
          secondary: '',
          success: '',
          info: '',
          warning: '',
          error: '',
          neutral: ''
        },
        highlightColor: {
          primary: {
            indicator: 'bg-(--ui-primary)'
          },
          secondary: {
            indicator: 'bg-(--ui-secondary)'
          },
          success: {
            indicator: 'bg-(--ui-success)'
          },
          info: {
            indicator: 'bg-(--ui-info)'
          },
          warning: {
            indicator: 'bg-(--ui-warning)'
          },
          error: {
            indicator: 'bg-(--ui-error)'
          },
          neutral: {
            indicator: 'bg-(--ui-bg-inverted)'
          }
        },
        active: {
          false: {
            link: [
              'text-(--ui-text-muted) hover:text-(--ui-text)',
              'transition-colors'
            ]
          }
        },
        highlight: {
          true: {
            list: 'ms-2.5 ps-4 border-s border-(--ui-border)',
            item: '-ms-px'
          }
        }
      },
      compoundVariants: [
        {
          color: 'primary',
          active: true,
          class: {
            link: 'text-(--ui-primary)',
            linkLeadingIcon: 'text-(--ui-primary)'
          }
        },
        {
          color: 'neutral',
          active: true,
          class: {
            link: 'text-(--ui-text-highlighted)',
            linkLeadingIcon: 'text-(--ui-text-highlighted)'
          }
        }
      ],
      defaultVariants: {
        color: 'primary',
        highlightColor: 'primary'
      }
    }
  }
})
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from '@nuxt/ui/vite'

export default defineConfig({
  plugins: [
    vue(),
    ui({
      uiPro: {
        contentToc: {
          slots: {
            root: 'sticky top-[calc(var(--ui-header-height)+1px)] bg-(--ui-bg)/75 lg:bg-[initial] backdrop-blur -mx-4 px-4 sm:px-6 sm:-mx-6 overflow-y-auto max-h-[calc(100vh-var(--ui-header-height))]',
            container: 'pt-4 sm:pt-6 pb-2.5 sm:pb-4.5 lg:py-8 border-b border-dashed border-(--ui-border) lg:border-0 flex flex-col',
            top: '',
            bottom: 'mt-6 hidden lg:flex lg:flex-col gap-6',
            trigger: 'group text-sm font-semibold flex-1 flex items-center gap-1.5 py-1.5 -mt-1.5 focus-visible:outline-(--ui-primary)',
            title: 'truncate',
            trailing: 'ms-auto inline-flex gap-1.5 items-center',
            trailingIcon: 'size-5 transform transition-transform duration-200 shrink-0 group-data-[state=open]:rotate-180 lg:hidden',
            content: 'data-[state=open]:animate-[collapsible-down_200ms_ease-out] data-[state=closed]:animate-[collapsible-up_200ms_ease-out] overflow-hidden focus:outline-none',
            list: '',
            listWithChildren: 'ms-3',
            item: '',
            itemWithChildren: '',
            link: 'group text-sm block truncate focus-visible:outline-(--ui-primary) py-1',
            indicator: 'absolute ms-2.5 transition-[translate,height] duration-200 h-(--indicator-size) translate-y-(--indicator-position) w-px rounded-full'
          },
          variants: {
            color: {
              primary: '',
              secondary: '',
              success: '',
              info: '',
              warning: '',
              error: '',
              neutral: ''
            },
            highlightColor: {
              primary: {
                indicator: 'bg-(--ui-primary)'
              },
              secondary: {
                indicator: 'bg-(--ui-secondary)'
              },
              success: {
                indicator: 'bg-(--ui-success)'
              },
              info: {
                indicator: 'bg-(--ui-info)'
              },
              warning: {
                indicator: 'bg-(--ui-warning)'
              },
              error: {
                indicator: 'bg-(--ui-error)'
              },
              neutral: {
                indicator: 'bg-(--ui-bg-inverted)'
              }
            },
            active: {
              false: {
                link: [
                  'text-(--ui-text-muted) hover:text-(--ui-text)',
                  'transition-colors'
                ]
              }
            },
            highlight: {
              true: {
                list: 'ms-2.5 ps-4 border-s border-(--ui-border)',
                item: '-ms-px'
              }
            }
          },
          compoundVariants: [
            {
              color: 'primary',
              active: true,
              class: {
                link: 'text-(--ui-primary)',
                linkLeadingIcon: 'text-(--ui-primary)'
              }
            },
            {
              color: 'neutral',
              active: true,
              class: {
                link: 'text-(--ui-text-highlighted)',
                linkLeadingIcon: 'text-(--ui-text-highlighted)'
              }
            }
          ],
          defaultVariants: {
            color: 'primary',
            highlightColor: 'primary'
          }
        }
      }
    })
  ]
})
Some colors in compoundVariants are omitted for readability. Check out the source code on GitHub.