Create a Dynamic Table of Contents in WordPress Without a Plugin (easy to follow steps)

What you’re seeing is a table of contents for a blog post. Including one in your article is part of a good user experience because it provides an easier way to skip between headings and gives the user an idea of the topics covered in your post.

One approach to creating a table of contents for WordPress is to write a PHP function that uses regular expressions to search the content for HTML heading elements. Give each heading element an anchor using the id attribute and then generate a list of links to the anchors. When someone clicks on a link from the table of contents, the browser will scroll to the desired section. Continue reading to follow these steps in detail.

PHP Function to Dynamically Insert a Table of Contents in WordPress Posts and Pages

<?php
// declare a function and pass the $content as an argument
function insert_table_of_contents($content) {

	// used to determine the location of the
	// table of contents when $fixed_location is set to false
	$html_comment = "<!--insert-toc-->";
	// checks if $html_comment exists in $content
	$comment_found = strpos($content, $html_comment) ? true : false;
	// set to true to insert the table of contents in a fixed location
	// set to false to replace $html_comment with $table_of_contents
	$fixed_location = true;

	// return the $content if
	// $comment_found and $fixed_location are false
	if (!$fixed_location && !$comment_found) {
		return $content;
	}

	// exclude the table of contents from all pages
	// other exclusion options include:
	// in_category($id)
	// has_term($term_name)
	// is_single($array)
	// is_author($id)
    if (is_page()) {
        return $content;
    }
		
	// regex to match all HTML heading elements 2-6
	$regex = "~(<h([2-6]))(.*?>(.*)<\/h[2-6]>)~";

	// preg_match_all() searches the $content using $regex patterns and
	// returns the results to $heading_results[]
	//
	// $heading_results[0][] contains all matches in full
	// $heading_results[1][] contains '<h2-6'
	// $heading_results[2][] contains '2-6'
	// $heading_results[3][] contains '>heading title</h2-6>
	// $heading_results[4][] contains the title text
	preg_match_all($regex, $content, $heading_results);

	// return $content if less than 3 heading exist in the $content
	$num_match = count($heading_results[0]);
	if($num_match < 3) {
		return $content;
	}

	// declare local variable
	$link_list = "";
	// loop through $heading_results
	for ($i = 0; $i < $num_match; ++ $i) {

	    // rebuild heading elements to have anchors
	    $new_heading = $heading_results[1][$i] . " id='$i' " . $heading_results[3][$i];

	    // find original heading elements that don't have anchors
	    $old_heading = $heading_results[0][$i];

	    // search the $content for $old_heading and replace with $new_heading
		$content = str_replace($old_heading, $new_heading, $content);

	    // generate links for each heading element
	    // each link points to an anchor
	    $link_list .= "<li class='heading-level-" . $heading_results[2][$i] .
	    	"'><a href='#$i'>" . $heading_results[4][$i] . "</a></li>";
	}
	    
	// opening nav tag
	$start_nav = "<nav class='table-of-content'>";
	// closing nav tag
	$end_nav = "</nav>";
	// title
	$title = "<h2>Table of Contents</h2>";

	// wrap links in '<ul>' element
	$link_list = "<ul>" . $link_list . "</ul>";

	// piece together the table of contents
	$table_of_contents = $start_nav . $title . $link_list . $end_nav;

	// if $fixed_location is true and
	// $comment_found is false
	// insert the table of contents at a fixed location
	if($fixed_location && !$comment_found) {
		// location of first paragraph
		$first_paragraph = strpos($content, '</p>', 0) + 4;
		// location of second paragraph
		$second_paragraph = strpos($content, '</p>', $first_p_pos);
		// insert $table_of_contents after $second_paragraph
	return substr_replace($content, $table_of_contents, $second_paragraph + 4 , 0);
	}
	// if $fixed_location is false and
	// $comment_found is true
	else {
		// replace $html_comment with the $table_of_contents
		return str_replace($html_comment, $table_of_contents, $content);
	}
} 
// pass the function to the content add_filter hook
add_filter('the_content', 'insert_table_of_contents');

In this section, we declared a function and passed it to the add_filter() hook. We can discuss the steps in detail as we go.

// declare a function and pass the $content as an argument
function insert_table_of_contents($content) {
}
// pass the function to the content add_filter hook
add_filter('the_content', 'insert_table_of_contents');

First, we used the function keyword to declare the function insert_table_of_contents and passed the $content as an argument.

The $content is a heavily used variable in WordPress. It stores the content of the current page or post.

Second, we passed the new function to the content filter hook.

The add_filter() is a WordPress function that makes it possible to modify the content before it gets printed to the screen. For example, if someone visits a web page, WordPress retrieves the page’s content from the database and stores it in the $content variable.

When we need to make changes to the content, we modify, or filter the $content variable and then pass it back to WordPress using the add_filter() hook.

If we don’t use the add_filter() hook, WordPress will never know we made changes to the content, and our progress will go unnoticed. It will be as if we never filtered the $content.

Excluding the Table of Contents From Pages and Other Criteria

Before diving into creating a table of contents, let’s first decide on where to exclude it from.

// exclude the table of contents from all pages
// Other exclusion options include:
// in_category($id)
// has_term($term_name)
// is_single($array)
// is_author($id)
if (is_page()) {
    return $content;
}

It’s all based on preference, but the code comments should give you an idea.

Regex to Match All Heading Tags in the Content

In this section, we will match all the HTML heading elements in the $content by using regular expressions and the PHP function preg_match_all().

Regular expressions, or regex for short, are patterns used for matching text, similar to how command + F works on a computer.

preg_match_all()

// regex to match all HTML heading elements 2-6
$regex = "~(<h([2-6]))(.*?>(.*)<\/h[2-6]>)~";

// preg_match_all() searches the $content using $regex patterns and
// returns the results to $heading_results[]
//
// $heading_results[0][] contains all matches
// $heading_results[1][] contains '<h2-6'
// $heading_results[2][] contains '2-6'
// $heading_results[3][] contians '>heading title</h2-6>
// $heading_results[4][] title
preg_match_all($regex, $content, $heading_results);

The first argument passed to preg_match_all() is the regex pattern, the second argument is the article content, and the third argument is an empty array for storing any matches.

Said differently, we are telling preg_match_all() to search the $content using $regex patterns and to return everything it finds to $heading_results.

let’s explore a useful tool for testing regular expression.

Tool to Write and Test Regex

We’ll use a website called phpliveregex.com to test and develop our regex.

While on phpliveregex.com, click on the “preg_match_all()” tab on the right-hand side of the webpage. Also, notice the two input fields labeled “regex” and “test string.”

phpliveregex.com

We can use an HTML copy of any article for the test-string and then write the regex to match the HTML heading elements in the test-string.

// regex to match all HTML heading elements 2-6
$regex = "~(<h([2-6]))(.*?>(.*)<\/h[2-6]>)~";

The given regex code will match all level-2 through level-6 HTML heading elements in the test string.

heading results

Notice that the regex matched 15 results as indicated by the array. If we manually count the number of heading elements in the test-string, we’ll count 15 in total. That tells us that the regex patterns work as intended.

The regex also returned a total of 5 arrays, each representing a capture group.

A capture group is achieved by surrounding a regex pattern in parenthesis like this (.*).

Let’s try to examine the results.

  • Array number 0 has a match of the heading elements in their entirety.
  • Array number 2 has a match of the heading level number.
  • Array number 4 has a match of the heading title text.
heading results arrays

It will make sense later why we separated the headings elements into different arrays. But for the sake of clarity, let’s give an example. If we need the title of the heading without the HTML part, we can use array number 4. If we only need the heading level number alone, we can use array number 2, etc.

Does the Content Have Headings?

We need to determine if the content has headings. If it doesn’t, we can assume that the article does not require a table of contents. In that case, we exit the function.

Recall that the heading matches can be accessed using the array $heading_results.

// return $content if less than 3 heading tags exist in the $content
$num_match = count($heading_results[0]);
if($num_match < 3) {
	return $content;
}

The variable $num_match tracks the number of headings matched by using the count() function on $heading_results. The if-statement checks if the content has less than 3 heading elements. If it does, we return the $content.

Looping Through HTML Heading Elements

As of now, we have a way of accessing all the HTML heading elements in the content.

In this section, we will use a for-loop the and $heading_results areay to rebuild the heading elements to have an anchor. Consequently, we’ll replace the original heading elements that don’t have anchors with the ones we created. Finally, we’ll generate the links for the table of contents and exit the for-loop.

// declare local variable
$link_list = "";
// loop through $heading_results
for ($i = 0; $i < $num_match; ++ $i) {

    // rebuild heading elemens to have anchors
    $new_heading = $heading_results[1][$i] . " id='$i' " . $heading_results[3][$i];

    // find original heading elements that don't have anchors
    $old_heading = $heading_results[0][$i];

    // search the $content for $old_heading and replace with $new_heading
	$content = str_replace($old_heading, $new_heading, $content);

    // generate links for each heading element
    // each link points to an anchor
    $link_list .= "<li class='table-of-content-list heading-level-" . $heading_results[2][$i] . "'><a href='#$i'>" . $heading_results[4][$i] . "</a></li>";
}

For-Loop Syntax

  1. We started the for-loop with the keyword “for” and a pair of parentheses.
  2. Inside the parenthesis, we have three statements separated by semicolons.
  3. The first statement declares a temporary variable and sets it equal to zero.
  4. The second statement compares $i to $num_match. This condition means: stay in the loop until $i is no longer less than $num_match.
  5. The last statement increments $i by 1 on every iteration until $i is no longer less than $num_much and the for-loop ends. 
// for-loop syntax
for ($i = 0; $i < $num_match; ++ $i) {
}

Rebuild Heading Elements to Have Anchors

To automatically scroll to the desired section when someone clicks on a link from the table of contents, each heading element needs an anchor.

We can create an anchor by using the id attribute on the heading elements. $new_heading uses $heading_results and the id attribute to create heading elements with anchors.

// rebuild heading elemens to have anchors
$new_heading = $heading_results[1][$i] . " id='$i' " . $heading_results[3][$i];

The variable $i has a unique value on every iteration, thus satisfying the requirement of giving every HTML element a unique id attribute.

Find and Replace Original Headings

The next step is to search the $content for the original headings and replace them with the headings we created.

// find original heading elements that don't have anchors
$old_heading = $heading_results[0][$i];

// search the $content for $old_heading and replace with $new_heading
$content = str_replace($old_heading, $new_heading, $content);

Recall that array number 0 in $heading_results[0] contains the HTML heading elements we want to replace. We can use the array to store the headings in the variable $old_heading.

str_replace() uses the $content to search for $old_heading and replace it with $new_heading.

Generating Table of Contents Links

The next step is to create a link for every heading. When someone clicks on a link from the table of contents, the browser will scroll to the anchored heading.

// generate links for each heading element
// each link points to an anchor
$link_list .= "<li class='table-of-content-list heading-level-" . $heading_results[2][$i] . "'><a href='#$i'>" . $heading_results[4][$i] . "</a></li>";

We’ll use $link_list to store all the links we create.

Notice that we declared $link_list before we entered the for-loop. We did that so that we can assign $link_list to itself on every iteration using the concatenation operator.

The dot preceding the equals sign $link_list .= is called a concatenation operator. It combines the links into a single variable.

As you can see, $link_list uses the $heading_results array to build the links. Each link is wrapped inside a list element for styling purposes, but more on that shortly.

Notice the hash and the $i variable inside the link element. That tells the browser that the link is referring to an id attribute in the HTML document. Because we created HTML headings with anchors, the links will now refer to those headings.

Also, note that the link text is just the heading title accessed using $heading_results[4][$i].

Styling the Links

The list element, denoted by <li></li>, stacks the links neatly one above the other instead of having them jumbled into a paragraph. Let’s print $link_list to the screen to get an idea of what’s happening.

// print $link_list
print $link_list;

The following image gives us a visual of the output.

unstyled table of contents

Note that each list item has a CSS class that corresponds to a heading level.

// CSS class for each heading level
heading-level-" . $heading_results[2][$i]

By referencing the regex website, we can see that array number-2 has all the heading levels. We can use that to style the links based on the heading level they reference.

The benefit of adding a CSS class for different headings is to have more control over the links’ appearance. In this case, we gave each link referring to a heading element below level-2 a margin-left: 40px.

// headings tags below level-2 are pushed to the right by 40px
// you can also choose to style each heading element differently
 .heading-level-3,
 .heading-level-4,
 .heading-level-5,
 .heading-level-6 {
    margin-left: 40px;
 }

Notice how the links are organized and how lower-level headings are pushed to the right by 40px after applying the styles.

styled table of contents

Piecing Together the Table of Contents

We’ve already done a lot inside the for-loop. Now, let’s piece everything together so that the table of contents is contained in a single variable.

// opening nav tag
$start_nav = "<nav class='table-of-content accent-colors'>";
// closing nav tag
$end_nav = "</nav>";
// title
$title = "<h2 class='stacked-ii accent-colors secondary-title'>Table of Contents</h2>";

// wrap links in '<ul>' element
$link_list = "<ul>" . $link_list . "</ul>";

// piece together the table of contents
$table_of_contents = $start_nav . $title . $link_list . $end_nav;

Let’s draw our attention to the $table_of_contents variable. Notice that it consists of a navigation tag, a title, and the $link_list variable.

We can test the table of contents by printing it to the screen to verify that everything checks out.

// test $table_of_contents by printing it to the screen
print $table_of_contents;

We should be able to verify that the table of contents is wrapped in an HTML navigation element and has a title. The links should also be wrapped in an unordered list element.

Inserting the Table of Contents

In this section, we will insert the table of contents at a fixed location for all posts and pages but also introduce a manual way to insert it at a specific location.

We can insert the $table_of_contents after the first paragraph, the second paragraph, or a set number of characters. With a little imagination, anything will work.

I prefer to insert the table of contents on all my posts at a fixed location, but sometimes I want to specify where to place the table of contents.

We can write an if-statement that inserts the table of contents on all posts except on those that have a special HTML comment. We can manually insert the HTML comment in the post where we want the table of contents to appear.

The added benefit of using an HTML comment is that it isn’t visible to the user, even if this function gets deleted in the future.

// used to determine the location of the
// table of contents when $fixed_location is set to false
$html_comment = "<!--insert-toc-->";
// checks if $html_comment exists in $content
$comment_found = strpos($content, $html_comment) ? true : false;
// set to true to insert the table of contents in a fixed location
// set to false to replace $html_comment with $table_of_contents
$fixed_location = true;

// if $fixed_location is true and
// $comment_found is false
// insert the table of contents at a fixed location
if($fixed_location && !$comment_found) {
	// location of first paragraph
	$first_paragraph = strpos($content, '</p>', 0) + 4;
	// location of second paragraph
	$second_paragraph = strpos($content, '</p>', $first_p_pos);
	// insert $table_of_contents after $second_paragraph
return substr_replace($content, $table_of_contents, $second_paragraph + 4 , 0);
}
// if $fixed_location is false
else {
	// replace $html_comment with the $table_of_contents
	return str_replace($html_comment, $table_of_contents, $content);
}

First, we used the variable $html_comment to represent our HTML comment and $comment_found to tell us if the post has the comment. Then we used $fixed_location to know if we should insert the table of contents at a fixed location.

Here are the possible scenarios:

  1. $fixed_location is true and $comment_found is false. Here, we insert the table of contents after the second paragraph.
  2. $fixed_location is true and $comment_found is true. We search the content for $html_comment and replace it with $table_of_contents
  3. $fixed_location is false and $comment_found is true. Same as scenario number-2.
  4. $fixed_location is false and $comment_found is false. The table of contents is not inserted into the post. This scenario can be optimized by returning the $content at the beginning of the function. The final code in the conclusion section will include this optimization.

All scenarios are accomplished using an if-statement and an else-statement.

  • The if-statement inserts the table of contents after the second paragraph of each post but can be easily changed to insert it after the first paragraph by replacing the third argument in sbstr_replace() with $first_paragraph.
  • The else-statement uses str_replace() to search the $content for the $html_comment and replaces it with $table_of_contents.

Conclusion

Congratulations on making it through this tutorial. Our function is now complete. Don’t forget to style the table of contents to your liking. Be creative!