Easily add a table of contents to any post without a plugin in WordPress

Last modified May 15, 2023
.* :☆゚
Gutenberg blocks adds an additional class to headings. As such, the markup below has been updated to accommodate this change. If you are using WordPress without Gutenberg, you may have to change the regex to account for this.

In longform articles it’s pretty common to see a table of contents at the very start of the article (for example, these blogs).

I recently tried many of the most popular TOC plugins for WordPress to save some time in the project I’m currently working on, but they didn’t 100% meet my needs because I am using ACF with Gutenberg, which meant a lot of content is interspersed with custom ACF blocks.

I also found it really hard to find a solution that allowed me to place the table anywhere I wanted on the page. This is a big issue if you want to use position:sticky on the table of contents, because stickying an element requires a very specific relationship with it’s parent that may not be possible with some layout designs.

So, I made my own simple solution, which I’m sharing with you all today.

To give you the most flexibility for your own development, I’ve provided a manual and automatic solution below, depending on your (or your client’s) level of commitment to the content. 😁

The most flexible, albeit manual way

Now, if I were to implement a table of contents on my own site I’d want to be able to manually curate the links for an article myself.

You see, the problem with automatically generating a toc like most plugins do, is that it is way harder to create custom anchor links (which will be useful for targeted SEO), and you may want to omit or add certain headings to the list as well for certain articles.

To give the most flexibility, this method will simply check the post content for any headings with custom anchors attached and generate a list of links based on those headings.

1. In the post editor, add anchors to the headings you want to include in the article’s table of contents.

<h2 id="what-i-did-today">What I did today</h2>

Note that if you are using Gutenberg, you can edit the heading’s anchor link visually in the block editor under the Advanced tab:

editing a heading’s anchor in gutenberg

2. Paste this code in your page template where you want the toc to appear. For example, single.php.

<div class="toc">
  <?php
  $content = get_the_content();
  preg_match_all('/<(h\d*).*?(id="(.*?)")>((.*?))</',$content,$matches);
  $levels = $matches[1];
  $anchors = $matches[3];
  $headings = $matches[4];
  if ( $headings ) {
    echo '<div class="title">Table of Contents</div>';
    function collate_row($depth, $anchor, $heading) {
        $level = substr($depth, 1);
        return ["<a href='#{$anchor}' class='{$depth} toc-link'>{$heading}</a>",intval($level)];
    }

    $collated = array_map('collate_row', $levels, $anchors, $headings );
    $previous_level = 2;
    echo '<ol class="toc-list">';
      foreach ($collated as $key=>$row) {
        $current_level = $row[1];

        if (  $current_level == $previous_level ) {
            if ( $key === 0 ) {
                echo '<li>' . $row[0];
            } else {
                echo '</li><li>' . $row[0];
            }
        } else if (  $current_level < $previous_level ) {
            echo str_repeat('</ol>', $previous_level - $current_level) . '<li>'. $row[0] . '</li>';
        } else {
            echo '<ol><li>' . $row[0]. '</li>';
        }

        $previous_level = $row[1];

      }

    echo '</li></ol>';

    $previous_level = $row[1];

    }
    //close off the list
    echo str_repeat('</ol>', $previous_level) . '</li></ol>';
  }
?>
</div>

Essentially what we’re doing is retrieving the entirety of post content, and using regular expression to capture all of the headings in the article along with any associated anchor ids we manually assigned earlier.

Using preg_match_all() we can assign variables to the gathered data. In this case we’ve retrieved the anchor ids, heading level, and heading title, all in one go.

Once we have these variables, we do a check to see if any headings actually exist (you could also add a condition that passes if there are more than three headings in the article, for example). If there are, we can begin building the markup for the table of contents.

array_map() makes this process extremely simple and easy- instead of using foreach loops to loop through the different arrays of data, we can just create a simple function that outputs a link for each heading by combining the rows from all three of our custom variables holding the different arrays of data.

Note that this snippet also formats the headings into a nested, unordered list. If headings are being used correctly in your articles, the markup that this code generates should reflect the hierarchy in the article (this is for optimal SEO and accessiblity).

You should end up with markup looking something like this:

<div class="toc">
  <div class="title">Table of Contents</div>
  <ol class="toc-list">
    <li><a href="#problem" class="h2 toc-link">Problem</a>
      <ol>
        <li><a href="#test" class="h3 toc-link">test</a></li>
      </ol>
    </li>
    <li><a href="#solution" class="h2 toc-link">Solution</a></li>
    <li><a href="#results" class="h2 toc-link">Results</a></li>
    <li><a href="#Project-in-detail" class="h2 toc-link">Project in detail</a>
      <ol>
        <li><a href="#asdsadasd" class="h3 toc-link">asdsadasd</a>
          <ol>
            <li><a href="#asdasd" class="h4 toc-link">asdasd</a></li>
          </ol>
        </li>
      </ol>
    </li>
    <li><a href="#test2" class="h2 toc-link">test2</a></li>
  </ol>
</div>

With all the barebones markup in place, you are now able to style the table of contents as you wish with CSS.

In my own development this is what my own table of contents looks like (note this screenshot is from a rough proof of concept stage):

TOC example

Second option: Automate it all

If you just want to automatically generate a TOC for all your articles without having to manually set an id on each heading, there is a really quick and easy way to do this in WordPress.

In many dynamically generated TOC solutions, inevitably JavaScript may be used to add anchors dynamically. In my opinion, this is a really clunky way to manage content and not only can you run into unintentional problems with case matching especially in nested content, it does not degrade gracefully, meaning users who don’t have JS in their browser for whatever reason won’t be able to use your Table of Contents.

This is where the advantages of using WordPress can really come into play, because you can use its powerful filters to find and replace text before the page renders. This makes this method entirely PHP-based, which is great for progressive enhancement.

Insert the slightly modified code below to the page template where you want the TOC to appear.

For example, single.php.

<div class="toc">
  <?php
  $content = get_the_content();
  preg_match_all('/<(h\d*).*?(?: id="(.*?)")?>((.*?))</',$content,$matches);
  $levels = $matches[1];
  $anchors = $matches[2];
  $headings = $matches[3];
  if ( $headings ) {
      echo '<div class="title">Table of Contents</div>';
      function collate_row($depth, $anchor, $heading) {
          $level = substr($depth, 1);
          if ( $anchor ) {
              return ["<a href='#{$anchor}' class='{$depth} toc-link'>{$heading}</a>", $level];
            } else {
              $slug = sanitize_title($heading);
              return ["<a href='#{$slug}' class='{$depth} toc-link'>{$heading}</a>", $level];
            }
      }

      $collated = array_map('collate_row', $levels, $anchors, $headings );
      $previous_level = 2;
      echo '<ol class="toc-list">';
        foreach ($collated as $key=>$row) {
        $current_level = $row[1];

        if (  $current_level == $previous_level ) {
            if ( $key === 0 ) {
                echo '<li>' . $row[0];
            } else {
                echo '</li><li>' . $row[0];
            }
        } else if (  $current_level < $previous_level ) {
            echo str_repeat('</ol>', $previous_level - $current_level) . '<li>'. $row[0] . '</li>';
        } else {
            echo '<ol><li>' . $row[0]. '</li>';
        }

        $previous_level = $row[1];

      }

    echo '</li></ol>';

    $previous_level = $row[1];
  }
  ?>
</div>

Note that the regex has changed slightly as I’ve now made the id capture group optional.

This method also ensures the admin has agency when it comes to customising a heading’s anchor. If a heading already has a custom id attached to it, the code will form markup based on that id. If not, the code will convert the heading to a slug and use that as the heading’s anchor id.

Append automatically generated anchors to all the headings in our post content.

As noted earlier, we will make use of WordPress’ filters to scan the post content and replace all headings instances with a slug generated from it.

With full credit to Jeroen Sormani, he has written an amazingly useful snippet which does this for us. Simply paste the following into your functions.php file and save.

// full credit for this code goes to: https://jeroensormani.com/automatically-add-ids-to-your-headings/
function auto_id_headings( $content ) {

	$content = preg_replace_callback( '/(\<h[1-6](.*?))\>(.*)(<\/h[1-6]>)/i', function( $matches ) {
		if ( ! stripos( $matches[0], 'id=' ) ) :
			$matches[0] = $matches[1] . $matches[2] . ' id="' . sanitize_title( $matches[3] ) . '">' . $matches[3] . $matches[4];
		endif;
		return $matches[0];
	}, $content );

    return $content;

}
add_filter( 'the_content', 'auto_id_headings' );

Your automatically generated table of contents should now link appropriately to whatever heading is in your article!

Big shoutout to one of my favourite WordPress functions here, sanitize_title() 😄. It may seem like a simple function but I’ve always found it so useful in my everyday work.

Now, you should have a fully functioning table of contents for your articles. Let me know how you go if you used this method!

Now would also be a great time to add smooth scroll behaviour to make the experience even better. 😁