
Table of Contents
Requirements and context
The basis of the table of contents is simple enough that I think everyone should be familiar with it. When the user clicks on the link in the table of contents, which then will lead them to the header associated with it. This is a simple a tag and an id on the HTML element. We will use this simple method to achieve this feature.
The first thing that we need from Sanity is all of the headings that we want to display on the table of contents.
Limitation
This blog post will not cover the nested header element. For instance, if your content has an h2 and an h3 nested inside, this approach will not work.
Once we have all of the headings in that post, we want to put them in a component so that we can display them accordingly. Once we have a table of contents component, we will just anchor tag and an ID to do matching, which will result in the same page navigation.
Query all headers in your post
In the query that you fetch your post, you want to add a field called headings. Which will look something like this.
const postFields = /* groq */ `
_id,
"status": select(_originalId in path("drafts.**") => "draft", "published"),
"title": coalesce(title, "Untitled"),
"slug": slug.current,
excerpt,
coverImage,
categories->{name},
"date": coalesce(date, _updatedAt),
"author": author->{firstName, lastName, picture},
//ADD THIS LINE BELOW
"headings": content[style in ["h2", "h3", "h4", "h5", "h6"]],
`;
Now, since this is a return object from GROQ queries, you can name it anything you want. It doesn’t need to be called headings. But since this seems to make the most sense. Please note that the array of headings starts with content, in my case. You might need to use a different keyword here, depending on how you name your data object.
To emphasise this, I have attached a code below where I fetch the data from Sanity, which I call content.
export const postQuery = defineQuery(`
*[_type == "post" && slug.current == $slug] [0] {
content[]{
...,
markDefs[]{
...,
${linkReference}
}
},
${postFields}
}
`);
If you called it something else, like body, you need to adjust it accordingly.
If you are confused by this query, I have attached a link to the Sanity cheat sheet here.
Create a Table of contents component to display header tags
Once we have all of the header tags that we need, we can start put them in the component.
type TableOfContentProps =
| {
children?: {
marks?: string[];
text?: string;
_type: 'span';
_key: string;
}[];
markDefs?: any[] | undefined;
style?: 'blockquote' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'normal';
_key: string;
_type: string;
}[]
| null;
const TableOfContent = ({ headings }: { headings: TableOfContentProps }) => {
if (!headings) return null;
return (
<div className='my-5'>
<h2 className='text-2xl'>Table of Contents</h2>
<ul className='ml-10 mt-5 list-disc'>
{headings?.map((heading, index) => {
if (!heading?.children[0].text) return;
return (
<li key={index} className='mb-2'>
<a
href={`#${URLFormatter(heading?.children[0].text)}`}
className='hover:underline'
>
{heading.children[0].text}
</a>
</li>
);
})}
</ul>
</div>
);
};
Let’s go through this piece of code one by one. This TableOfContent component will be responsible for displaying all of the header tags. If you notice, I have created a type for this component manually. You don’t have to do this, but if you use TypeScript, your formatter will throw some warnings.
The only caution for this approach is that you need to be aware that if the data of the props changes, this might crash. So if you keep that in mind, next time you make an update to this data object, you also need to update the props here.
If you notice that in this component, I check for a falsy value twice. The reason is that if there is no heading at all, I don’t want to display any of the content (rare case). Another falsy check value inside the map checks for empty heading tags. This means in the Sanity CMS editor, if you accidentally add a header tag but didn’t input any of the text, it will return a tag with an empty value. This check makes sure that if the data of the tag is empty, then we don’t want to display the li at all.
The function URLFormatter will be discussed in the next section.
Format anchor data
If you wonder why we need this URLFormatter. Just for fun, if you remove that function and just use heading?.children[0].text
you will notice that the URL will be in a non-friendly format. It will work in terms of functionality, but it's not readable. We want to make sure that all of the anchor tags and id of elements on the page are readable.
There are many different ways to format this piece of data. Below is just one example.
export const URLFormatter = (text: string): string => {
if (!text) return '';
return text
.toLowerCase()
.normalize('NFD') // Normalize unicode characters
.trim()
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
.replace(/[^a-z0-9\s-]/g, '') // Remove non-alphanumeric chars except spaces and hyphens
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Remove consecutive hyphens
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
};
This code is straightforward if you read it line by line. But if you are confusing with the functions use here. You can refer to this documentation.
Passing the value of the tags into the header component through the PortableTextComponent
Now we have a working anchor tag, the only thing left for us to do is to match the anchor tag data and the HTML element (which, in our case,is all of our headings).
To do that, we will head to the PortableText component. Your code might be different from what I have here, depending on what kind of template you use to init your project. But what we are trying to do here is to find the place where all of the data inside our block content gets rendered. We need to define explicitly what each element needs to look like.
const components: PortableTextComponents = {
block: {
h2: ({ children, value }) => {
return (
<h2
className='group relative'
id={URLFormatter(value?.children[0].text)}
>
{children}
<a
href={`#${URLFormatter(value?.children[0].text)}`}
className='absolute left-0 top-0 bottom-0 -ml-6 flex items-center opacity-0 group-hover:opacity-100 transition-opacity'
>
<svg
xmlns='http://www.w3.org/2000/svg'
className='h-4 w-4'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1'
/>
</svg>
</a>
</h2>
);
},
}
}
You should be able to find an object that looks something like this. This object here will be used to pass into a PortableText
component from next-sanity
. The only thing that we need to do here is to use the same data that we use on our anchor tag and apply it as an id to the header element. This code is an example for h2, you should repeat this for all of the headings that you expect from the content.
Note that in the code sample above, I have an a tag with a logo. The purpose of this is to create an anchor tag from the heading and share it with anyone. So when that person clicks on the link, it will lead them straight to the header or subheader that you click on.
That’s all for this post. I hope you enjoy reading my content. See you in the next one!