How to Automatically Add a Table of Contents (TOC) to Single Blog Posts in WordPress

Charis Valtzis

Charis Valtzis

· 3 min read

Long posts need a table of contents. Readers can jump to the part they came for, and Google sometimes shows "jump to" links under your result when the page has proper anchors.

There are plenty of TOC plugins. Most of them come with a settings page, their own CSS and JS, and features you'll never touch. If all you want is "list the H2s at the top of every post", a small filter does the job.

The code

Add this to your child theme's functions.php or a custom plugin:

function cv_generate_toc( $content ) {
    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
        return $content;
    }

    // Find all H2 tags in the post content
    preg_match_all( '/<h2.*?>(.*?)<\/h2>/s', $content, $matches );

    if ( empty( $matches[1] ) ) {
        return $content; // No H2 headings, no TOC
    }

    // Build the TOC
    ob_start();
    ?>
    <div id="toc-container" class="toc-container">
        <h3>Table of Contents</h3>
        <ul>
            <?php foreach ( $matches[1] as $heading ) :
                $clean_heading = wp_strip_all_tags( $heading );
                $slug = 'toc-' . sanitize_title( $clean_heading ); ?>
                <li><a href="#<?php echo esc_attr( $slug ); ?>"><?php echo esc_html( $clean_heading ); ?></a></li>
            <?php endforeach; ?>
        </ul>
    </div>
    <?php
    $toc = ob_get_clean();

    // Give each H2 an id so the links have somewhere to go
    foreach ( $matches[1] as $index => $heading ) {
        $clean_heading = wp_strip_all_tags( $heading );
        $slug = 'toc-' . sanitize_title( $clean_heading );
        $content = str_replace( $matches[0][$index], '<h2 id="' . esc_attr( $slug ) . '">' . $heading . '</h2>', $content );
    }

    return '<div class="content-toc-wrapper">' . $toc . '<div class="post-content">' . $content . '</div></div>';
}
add_filter( 'the_content', 'cv_generate_toc' );

What it does:

  1. On single posts only, it grabs every <h2> in the content.
  2. It turns each heading into a slug (toc-why-it-matters) using sanitize_title().
  3. It builds a list of links at the top and adds the matching id to each heading.

The in_the_loop() and is_main_query() check is there because the_content also runs in widgets, related posts blocks and so on. Without it you can end up with a TOC in places you didn't expect.

Smooth scrolling

You don't need JavaScript for this anymore. One line of CSS does it:

html {
  scroll-behavior: smooth;
}

If you have a sticky header that covers the heading after the jump, add some offset:

.post-content h2 {
  scroll-margin-top: 100px; /* roughly your header height */
}

Styling

A simple starting point. Adjust the colours to your theme:

.toc-container {
  background: #f9f9f9;
  border: 1px solid #e1e1e1;
  border-radius: 8px;
  padding: 20px;
  margin-bottom: 30px;
}

.toc-container h3 {
  font-size: 1.2rem;
  margin-bottom: 10px;
  padding-bottom: 5px;
  border-bottom: 1px solid #ddd;
}

.toc-container ul {
  list-style: none;
  padding-left: 0;
  margin: 0;
}

.toc-container li {
  margin: 6px 0;
}

.toc-container a {
  color: #0073aa;
  text-decoration: none;
}

.toc-container a:hover {
  text-decoration: underline;
}

Limitations

This is intentionally simple, so a few things it doesn't do:

If any of these become a problem, that's usually the point where a plugin is worth it. Until then, this gets you a working TOC without adding another plugin to the site.

#TOC#Hooks#User Experience#Content
← All postsHave a project in mind? Let's talk