You’ve probably noticed by now that the site’s theme includes a table of contents for each post. You might be wondering if I actually spend time creating that table of contents myself. Well, sorry to disappoint, but the answer is: No. It is in fact generated automatically based on the headings inside the article. I think it’s pretty slick, so I decided to share with you how it’s done.
Introduction
This is, in fact, a very popular approach to generating a table of contents. So common, that it is what MS Word uses to generate it too. There are actually 2 ways of generating a table of contents in WordPress:
- Through JavaScript, in the front end.
- Through PHP, when displaying the content.
Now, there’s no visual difference between the two approaches. But, from an SEO perspective, I chose the latter, because the TOC is also embedded into the page content itself.
The method itself consists of scraping all the heading elements and creating a list of them, where every item points to the heading itself inside the article.
So, let’s see how it’s done in code.
Generating the Table of Contents HTML
The first step is to generate the HTML. This means scraping the HTML content of the post to grab each heading element and append it to the newly created TOC.
To do this, we’re going to add a new filter for the_content
, from our theme’s functions.php
file.
First, let’s see how the entire code looks like, and then we’ll analyze it bit by bit.
// Inject the TOC on each post. add_filter('the_content', function ($content) { global $tableOfContents; $tableOfContents = " <div class='h5'> Table of Contents <span class='toggle'>+ show</span> </div> <div class='items'> <div class='item-h2'> <a href='#preface'>Preface</a> </div> "; $index = 1; // Insert the IDs and create the TOC. $content = preg_replace_callback('#<(h[1-6])(.*?)>(.*?)</\1>#si', function ($matches) use (&$index, &$tableOfContents) { $tag = $matches[1]; $title = strip_tags($matches[3]); $hasId = preg_match('/id=(["\'])(.*?)\1[\s>]/si', $matches[2], $matchedIds); $id = $hasId ? $matchedIds[2] : $index++ . '-' . sanitize_title($title); $tableOfContents .= "<div class='item-$tag'><a href='#$id'>$title</a></div>"; if ($hasId) { return $matches[0]; } return sprintf('<%s%s id="%s">%s</%s>', $tag, $matches[2], $id, $matches[3], $tag); }, $content); $tableOfContents .= '</div>'; return $content; });
The first and last lines register a new WordPress filter:
add_filter('the_content', function ($content) { // ... });
The $content
parameter is a parameter that’s going to be filled in by WordPress when it calls our new filter. It contains the HTML source code of our article.
The next line,
global $tableOfContents;
Defines the $tableOfContents
variable as global, so that we can reference it from outside the filter. This is because we don’t want to actually embed the TOC into the content itself, we want to display it in a separate column.
Next, the $tableOfContents
variable is initialized with a default item, because we don’t usually start articles with headings, so there would be no heading for the first part of the article. Therefore, we create one.
$tableOfContents = " <div class='h5'> Table of Contents <span class='toggle'>+ show</span> </div> <div class='items'> <div class='item-h2'> <a href='#preface'>Preface</a> </div> ";
The $index
is set to 1 and we’re going to use this to make sure that all of our headings have unique identifiers.
Next, we’re going to do a couple different things at the same time with the following instruction.
// Insert the IDs and create the TOC. $content = preg_replace_callback('#<(h[1-6])(.*?)>(.*?)</\1>#si', function ($matches) use (&$index, &$tableOfContents) { $tag = $matches[1]; $title = strip_tags($matches[3]); $hasId = preg_match('/id=(["\'])(.*?)\1[\s>]/si', $matches[2], $matchedIds); $id = $hasId ? $matchedIds[2] : $index++ . '-' . sanitize_title($title); $tableOfContents .= "<div class='item-$tag'><a href='#$id'>$title</a></div>"; if ($hasId) { return $matches[0]; } return sprintf('<%s%s id="%s">%s</%s>', $tag, $matches[2], $id, $matches[3], $tag); }, $content);
The preg_replace_callback() function is going to replace something based on a regex pattern, and for the replacer, is going to allow us to use a callback function. We need the callback function for 2 things:
- To grab each heading and add it to the
$tableOfContents
variable. - To give each heading in the article a unique ID, in order to be able later to link the TOC links to their respective headings.
To understand the regex pattern, there is a tutorial about Regex on our site, I recommend checking that out since it’s a very useful skill to learn.
'#<(h[1-6])(.*?)>(.*?)</\1>#si'
In short, we’re looking for HTML tags that start with the letter h
and end in 1, 2, 3, 4, 5, or 6. This should match all of the headings. At the same time, we’re grouping the tag name ((h[1-6])
), its attributes ((.*?)>
), and the tag’s inner HTML (>(.*?)</\1>
) into separate groups, to be able to use them individually in the callback function. We’re also using the s
flag, to make the .
selector match newlines, and the i
to ignore the case of the matched elements.
We are returning the preg_replace_callback()
result back into the $content
variable because we are adding IDs to the headings.
The callback function references $tableOfContents
and $index
so that we can have access to them from inside. We’re also using the &
operator to get their pointer reference so that we’ll be able to change their values.
The $matches
parameter is filled in by preg_replace_callback()
with the groupings matched based on the regex pattern.
Next, we’re going to use the first grouping as the $tag
.
$tag = $matches[1];
The next line uses the 3rd grouping as the title of the heading, but it strips the tags first, because there are cases where the heading contains other HTML elements, like <a>
or <strong>
. We don’t want those in the TOC, because they will mess either with our own links to the headings themselves, or with the style.
$title = strip_tags($matches[3]);
The next line:
$hasId = preg_match('/id=(["\'])(.*?)\1[\s>]/si', $matches[2], $matchedIds);
Checks if the currently matched element already has an id
attribute set. If it does, it’s going to be stored in the $matchedIds
variable.
Next, we create the unique ID that’s going to be used as a href
attribute on the TOC, and as an ID for the actual heading.
$id = $hasId ? $matchedIds[2] : $index++ . '-' . sanitize_title($title);
First, we check if it already had an ID. If it did, we’re going to use that one. However, if it did not have one, we’re going to create one based on the $index and the ‘slugified’ version of the title. A slug is a URL-friendly string, we’re just generating one from the heading content, using a built-in WordPress function called sanitize_title()
.
Next, we’re appending to the TOC the new element.
$tableOfContents .= "<div class='item-$tag'><a href='#$id'>$title</a></div>";
We’re using that class to style it later. We’re also going to point it with an <a>
element at the heading itself from inside the article.
Next:
if ($hasId) { return $matches[0]; } return sprintf('<%s%s id="%s">%s</%s>', $tag, $matches[2], $id, $matches[3], $tag);
We’re checking if it had an ID, and if it did, we’re returning the matched string, untouched.
If it did not have an ID, we’re going to return a modified version of the matched heading HTML, which includes the ID. This is essential in order to be able to link from the TOC to the heading itself through basic browser functionalities.
The next line simply appends a closing <div>
tag to the TOC.
$tableOfContents .= '</div>';
And then we’re going to return the modified $content
which now includes ID attributes for all the headings.
Using the Table of Contents
I’ve created a simple function to return the global $tableOfContents
variable. It’s not really necessary, but it keeps it consistent with the other WordPress components, and you don’t have to check for existance in your template.
function get_the_table_of_contents() { global $tableOfContents; return $tableOfContents; }
To use it, we simply use:
<?= get_the_table_of_contents() ?>
Inside the loop.
This is how it looks inside the template:
<div id="preface" class="post-content row"> <div class="content col"> <?php the_content() ?> </div> <?php if (is_single()): ?> <div class="post-toc col-auto"> <div class="wrapper"> <?= get_the_table_of_contents() ?> </div> <div class="placeholder"></div> </div> <?php endif ?> </div>
Notice the id="preface"
given to the content section, which our first link in TOC points to.
I’m using that in the singular.php
template file, which applies to both pages and posts, so the is_single()
check is added to make sure we only display this on single posts.
Styling the TOC
Now, this will differ from site to site, but if you choose to use this technique on yours, the only required step is to add some different padding on the left of each TOC item, based on it’s item-hX
class.
Here’s an example:
.item-h3 { padding-left: 15px; } .item-h4 { padding-left: 30px; } .item-h5 { padding-left: 45px; } .item-h6 { padding-left: 60px; }
I’m only styling from item-h3
onwards, because I never use h1
inside articles, so there won’t be any item-h1
, and since item-h2
is the first layer in the TOC, it doesn’t need any padding.
This styling gives it a more tree-like look, which makes it more easy to read.
Update
If you want to insert the number of the sections before each heading (1., 1.1., 1.2., etc), use this:
// Inject the TOC on each post. add_filter('the_content', function ($content) { global $tableOfContents; $tableOfContents = " <div class='h5'> Table of Contents <span class='toggle'>+ show</span> </div> <div class='items'> <div class='item-h2'> <a href='#preface'>1. Preface</a> </div> "; $index = 1; $indexes = [2 => 1, 3 => 0, 4 => 0, 5 => 0, 6 => 0]; // Insert the IDs and create the TOC. $content = preg_replace_callback('#<(h[1-6])(.*?)>(.*?)</\1>#si', function ($matches) use (&$index, &$tableOfContents, &$indexes) { $tag = $matches[1]; $title = strip_tags($matches[3]); $hasId = preg_match('/id=(["\'])(.*?)\1[\s>]/si', $matches[2], $matchedIds); $id = $hasId ? $matchedIds[2] : $index++ . '-' . sanitize_title($title); // Generate the prefix based on the heading value. $prefix = ''; foreach (range(2, $tag[1]) as $i) { if ($i == $tag[1]) { $indexes[$i] += 1; } $prefix .= $indexes[$i] . '.'; } $title = "$prefix $title"; $tableOfContents .= "<div class='item-$tag'><a href='#$id'>$title</a></div>"; if ($hasId) { return $matches[0]; } return sprintf('<%s%s id="%s">%s</%s>', $tag, $matches[2], $id, $matches[3], $tag); }, $content); $tableOfContents .= '</div>'; return $content; });
25 Replies to “WordPress: How to Generate a Table of Contents for Posts”
I really wanted a solution with PHP because i didn’t want to slow down my pages. I also didn’t want to use a plugin so this code was an amazing help to me.
I build it a little different though. I create the TOC when saving a post. I save the TOC in a custom field and i update the post content with the save_post hook.
This looks like EXACTLY the code I’ve been searching for. I placed the code in the functions.php file. It is filtering the H1-H6 as expected. However, I placed the get_the_table_of_contents() function in a file called in the get_template_part() function while the loop is running but it is not returning the list of links. I’ve been trying to debug but just can’t figure it out. What do you think I’m missing?
The first thing I’d check is to see if $tableOfContents is empty. If it is, then maybe the hook I’ve used is not the best one for you and you might want to look for something else instead of “the_content”. You can find a list of hooks on the official WordPress documentation.
Great! Thanks for the quick reply. Another question – could this code be modified to pass the post id through? If so can you suggest how? That might give me access to the return.
Sure, if you’re inside the loop just use the get_the_ID() helper.
Can you make a version support number before $title. Example: 1. -> 1.1, 1.2; 2 -> 2.1, 2.2…
Hello and thanks for writing in! That’s a great suggestion. You can do something like this:
I’ve also updated the post to include this version, at the end.
Hi,
I added the code to functions.php
and then using the code:
function get_the_table_of_contents()
{
global $tableOfContents;
return $tableOfContents;
}
if ( is_single() ) :
get_the_table_of_contents();
endif;
But it is not works…
I tried to add this:
add_action( ‘the_content’, ‘get_the_table_of_contents’ );
This line add the Table of Content, but the content itself removed.
How to use this also via functions.php?
You need to
echo
the returned value ofget_the_table_of_contents();
.if ( is_single() ) :
echo get_the_table_of_contents();
endif;
Hi, thank you,
I tried, this not works. Do you know why it is?
Hi,
I added the “echo” to this code, but it still don’t show. Can you help please?
Hi !
Is there a way to preload / prerender the content to show the TOC at the beginning of the post – without modifying the div order with css etc.?
Thanks!
Hello,
Yes, of course. You can move
wherever you want to TOC to be displayed inside the single.php template.
Does that answer you question?
Hello, I just want to say thank you to you, this tutorial really helped me a lot.
I tried it, and wow, it works well.
thank you for this tutorial, I hope you will be more successful going forward, and will continue to create something that can help others, like me who have just learned.
Best regards,
Ismail
Hello Cosmin,
I have one question, how do you set your style table of content, on a mobile device? I am very interested in doing the same thing, because it can help my users when they want to jump to an article section
Best regards
Ismail
Well, it’s the same HTML code, styled with CSS to stay fixed at the top after the user scrolls the page a bit. I’ve just added a toggle to show/hide the TOS. That’s all.
Hello Cosmin,
thank you for your reply, I have found a way to show / hide the TOS.
and then I will look for references to style CSS to make stay at the top after user scroll, or do you have references that I can use for this? I would be very grateful for that.
Best Regards
Ismail
Hi Cosmin,
if I put
the_content( );
after
echo get_the_table_of_contents();
TOC’s not gonna display at all. Actually nothing’s echo out.
but I just want to put TOC at the beginning of the article, So would I get a lucky quick response.
Thanks!
Hello,
Quickest ways to fix that is to have the TOS at the end of the article and then revert the divs with CSS.
Here’s a quick example: https://jsfiddle.net/v2wrgxL8/
Thank you for the quick reply. Then could I get a hint on how to .. I mean is there some solution that doesn’t need to put the_content( ); before echo get_the_table_of_contents();
because I am a little bit tired of CSS.
And if there’s not a simple way, please just forget about it. I didn’t mean to bother.
Thanks again. You are such a good one.
I don’t think you’ll find a quick solution for this specific approach.
The way to do what you need would be to use the post_save hook. These would be the steps to achieve that:
– Apply the code on save_post and generate the TOC;
– Save the TOC to a new database column or new post that’s linked to the current one;
– When displaying the post, fetch the TOC from the new stored location and display it anywhere you’d like.
Edit: Actually, you could also save the TOC inside the post itself, but then you’d need to replace it every time you update the post too.
Actually, I know nothing about PHP coding or any coding language. I just steal snippets here and there, and I like this one.
So, I’ll work on CSS then.
Thank you a lot!
Hi again, Cosmin! I just want you to know that I made it worked for me. In case someone may need it or not, I’m gonna put it here:
post_content; // this one cost me about 3 hours
$tableOfContents =”; //for my template it shoud be
$index = 1;
if($content1 == ”){return;}
// Insert the IDs and create the TOC.
$content1 = preg_replace_callback(‘#(.*?)#si’, function ($matches) use (&$index, &$tableOfContents) {
$tag = $matches[1];
$title = strip_tags($matches[3]);
$hasId = preg_match(‘/id=([“\’])(.*?)\1[\s>]/si’, $matches[2], $matchedIds);
$id = $hasId ? $matchedIds[2] : $index++ . ‘-‘ . sanitize_title($title);
$tableOfContents .= ”.$title.’‘;
if ($hasId) {
return $matches[0];
}
return sprintf(‘%s’, $tag, $matches[2], $id, $matches[3], $tag);
}, $content1);
if($tableOfContents == ”){
$tableOfContents .= ”;
}else{
$tableOfContents .= ”;
}
// if( isset( $_POST[ ‘toc’ ] ) ) {
// update_post_meta( $post_id, ‘toc’, ‘yes’ );
// } else {
// update_post_meta( $post_id, ‘toc’, ” );
// }
if($tableOfContents==”){
update_post_meta( $post_id, ‘toc’, ”);
}else{
update_post_meta( $post_id, ‘toc’, $tableOfContents, ”);
}
// return $content;
}
?>
// in single.php
Contents
Preface
Thank you sir, have a nice day!
Sorry, some characters missed.