
Creating a dynamic table of contents for your content can significantly improve navigation and user experience. In this guide, I'll show you how to implement an auto-generated table of contents using GROQ queries with Sanity and React.
Check out the live demo... literally off to the side on this page π (or at the top on mobile).
The GROQ Query#
The first step is to create a GROQ query that extracts headings from your PortableText content. Here's how to do it:
'headings': content[style in ['h2', 'h3', 'h4', 'h5', 'h6']]{
style,
'text': pt::text(@)
}This query selectively extracts only H2-H6 blocks from your content, ignoring H1 and other paragraph blocks. The special pt::text(...) function converts PortableText objects to plain text strings, while the @ symbol refers to the root value of the current scope (in this case, the heading blocks).
Converting Headings to Anchor Links#
Once we have our headings, we need to convert them to slugs that can be used as HTML id attributes for anchor linking. This allows users to click on a table of contents item and jump directly to that section of the page:
export function slug(
str: string,
{
removeLeadingNumberAndHyphen,
}: { removeLeadingNumberAndHyphen?: boolean } = {},
) {
const result = str
.toLowerCase()
.normalize('NFD') // Decompose combined characters (Γ© β e + Β΄)
.replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks (accents)
.replace(/[^\w\s-]/g, '') // Remove non-word characters except spaces and hyphens
.replace(/[\s_]+/g, '-') // Replace spaces and underscores with hyphens
.replace(/-+/g, '-') // Collapse multiple hyphens
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
.trim()
if (removeLeadingNumberAndHyphen) return result.replace(/^\d+-/, '')
return result
}Implementing the TOC Component#
The toc-item.tsx component handles the display and functionality of each table of contents item. Let's break down how it works:
- It takes a heading object with style and text properties
- Uses the
slug()utility to convert heading text to valid HTML IDs - Implements the Intersection Observer API to highlight the current section as the user scrolls
- Applies different indentation based on heading level (H3-H6)
Adding Visual Indicators#
The component includes logic to add a CSS class when a heading is in view, allowing you to visually indicate the current section to the user:
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
ref.current?.classList.add(css.inView)
} else {
ref.current?.classList.remove(css.inView)
}
})
},
{
threshold: 1,
rootMargin: `${document.documentElement.scrollHeight}px 0px -${thresholdHeight}px 0px`,
}
)Styling Based on Heading Level#
The component applies different indentation based on the heading level, creating a hierarchical visual structure:
className={cn('link block py-1 leading-tight', {
'pl-ch': stegaClean(heading.style) === 'h3',
'pl-[2ch]': stegaClean(heading.style) === 'h4',
'pl-[3ch]': stegaClean(heading.style) === 'h5',
'pl-[4ch]': stegaClean(heading.style) === 'h6',
})}View the source code in the SanityPress with Typegen repo.
Putting It All Together#
With these components in place, your table of contents will automatically:
- Extract headings from your content
- Generate anchor links for each heading
- Highlight the current section as users scroll
- Display a hierarchical structure based on heading levels
This implementation provides a seamless navigation experience for your users, especially for longer content pages where finding specific sections quickly is important.
You can find the complete implementation in the SanityPress with Typegen starter template, which provides this and many other useful components for building content-rich websites with Sanity and Next.js.
Read more on other helpful GROQ queries over on the original SanityPress blog!

"Table of Contents"

