Type something to search...
How to Add a Table of Contents to WordPress Posts?

How to Add a Table of Contents to WordPress Posts?

A table of contents turns a long post into something scannable, letting readers jump straight to the section they actually came for instead of scrolling blind. It's especially valuable for guides, tutorials, and reference posts (recipe blogs, technical documentation, long-form how-tos) where a reader often lands looking for one specific answer, not the whole article start to finish.

Beyond readability, a table of contents built from real anchor links can also earn "jump to section" sitelinks in Google search results, which is a meaningful click-through boost for content that already ranks. This guide covers a dedicated plugin route and a complete, working custom implementation that generates a table of contents automatically from your post's existing headings, no manual list-building required.

What a Good Table of Contents Needs

  • Anchor links that actually work, meaning every heading needs a unique id attribute for the link to jump to.
  • Automatic generation from headings, since manually maintaining a list that matches the post's ## structure is exactly the kind of thing that quietly goes stale after an edit.
  • Sensible heading-level filtering. Usually you want h2 and h3 in the table of contents, not every single h4 or h5, which would make the list too long to be useful.
  • A collapsible or sticky presentation on longer posts, so it doesn't push all the actual content below the fold.

Option 1: Use a Table of Contents Plugin

LuckyWP Table of Contents

LuckyWP Table of Contents is a lightweight, widely used free plugin that generates a table of contents automatically from your post's headings:

  • Automatically detects headings in post content and builds a linked list from them.
  • Configurable heading levels to include (for example, h2 and h3 only).
  • Options to auto-insert the table of contents after the first heading, or place it manually with a shortcode.
  • A "smooth scroll" option so clicking a link scrolls to the section instead of jumping instantly.

Setup:

  1. Install and activate LuckyWP Table of Contents.
  2. Go to Settings > LuckyWP Table of Contents to configure which post types it applies to, which heading levels to include, and where it auto-inserts.
  3. If you'd rather place it manually instead of relying on auto-insert, use the [lwptoc] shortcode anywhere inside a post.
  4. Preview a long post to confirm the generated list matches your actual heading structure and that clicking a link scrolls correctly.

Easy Table of Contents

Easy Table of Contents is a close alternative with a similar feature set, plus a few extra options: a configurable "minimum number of headings" before the table of contents appears at all (so a short post doesn't get a pointless three-line table of contents), and a few built-in visual themes to match different site styles.

Both plugins solve the same core problem the same way, scanning your post's rendered headings and building anchor links from them, so the choice mostly comes down to which settings screen and default styling you prefer.

Option 2: Build a Custom Table of Contents Generator

If you'd rather not add another plugin for something this focused, the code below is a complete, working implementation that hooks into the_content filter, finds every h2 and h3 in the post, injects an id on each one if it doesn't already have one, and builds a linked table of contents at the top of the post.

Step 1: The Core Generator Function

function tw_generate_table_of_contents( $content ) {
    // Only run on singular posts, and only if there are enough headings to bother with.
    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
        return $content;
    }

    // Match h2 and h3 tags, capturing any existing attributes and the inner text.
    preg_match_all( '/<h([2-3])(.*?)>(.*?)<\/h[2-3]>/i', $content, $matches, PREG_SET_ORDER );

    if ( count( $matches ) < 3 ) {
        return $content; // Not worth a table of contents for a short post.
    }

    $toc_items = [];
    $used_ids  = [];

    foreach ( $matches as $match ) {
        $level       = $match[1];
        $attrs       = $match[2];
        $heading_text = wp_strip_all_tags( $match[3] );

        // Reuse an existing id="" if the heading already has one, otherwise generate one.
        if ( preg_match( '/id=["\']([^"\']+)["\']/', $attrs, $id_match ) ) {
            $anchor_id = $id_match[1];
        } else {
            $anchor_id = sanitize_title( $heading_text );

            // Guarantee uniqueness in case two headings produce the same slug.
            $original_id = $anchor_id;
            $counter     = 2;
            while ( in_array( $anchor_id, $used_ids, true ) ) {
                $anchor_id = $original_id . '-' . $counter;
                $counter++;
            }

            $new_heading_open = "<h{$level}{$attrs} id=\"{$anchor_id}\">";
            $original_heading = "<h{$level}{$attrs}>{$match[3]}</h{$level}>";
            $updated_heading  = $new_heading_open . $match[3] . "</h{$level}>";
            $content          = str_replace( $original_heading, $updated_heading, $content );
        }

        $used_ids[]  = $anchor_id;
        $toc_items[] = [
            'level' => (int) $level,
            'id'    => $anchor_id,
            'text'  => $heading_text,
        ];
    }

    // Build the table of contents markup.
    $toc_html  = '<nav class="tw-toc" aria-label="Table of contents">';
    $toc_html .= '<p class="tw-toc-title">Table of Contents</p><ul class="tw-toc-list">';

    foreach ( $toc_items as $item ) {
        $indent_class = 3 === $item['level'] ? ' tw-toc-sub' : '';
        $toc_html    .= sprintf(
            '<li class="%s"><a href="#%s">%s</a></li>',
            esc_attr( $indent_class ),
            esc_attr( $item['id'] ),
            esc_html( $item['text'] )
        );
    }

    $toc_html .= '</ul></nav>';

    // Insert the table of contents right before the first heading.
    return preg_replace( '/<h[2-3]/', $toc_html . '$0', $content, 1 );
}
add_filter( 'the_content', 'tw_generate_table_of_contents' );

A few things worth understanding here, since this is doing real text processing rather than just formatting:

  • is_singular( 'post' ) && in_the_loop() && is_main_query() guards against the table of contents accidentally being generated for excerpts on the blog archive, in a sidebar "recent posts" widget, or anywhere else the_content filter happens to run outside the actual single post view.
  • The uniqueness check ($used_ids) matters because sanitize_title() will produce the exact same slug for two headings with the same text, like two sections both titled "Overview" in different parts of a long guide, and duplicate id attributes silently break anchor links for whichever one comes second.
  • Existing id attributes are preserved rather than overwritten, so if a heading already has an id (set manually, or by a page builder), the function reuses it instead of creating a second, disconnected anchor.
  • The regex only matches h2 and h3. Extending it to h2 through h4 (or restricting it to h2 only) is just a matter of changing the [2-3] character classes to match the range you want.

Step 2: Style the Table of Contents

.tw-toc {
  border: 1px solid #e5e5e5;
  border-radius: 8px;
  padding: 1.25rem 1.5rem;
  margin: 0 0 2rem;
  background: #fafafa;
}

.tw-toc-title {
  font-weight: 700;
  margin: 0 0 0.5rem;
}

.tw-toc-list {
  margin: 0;
  padding-left: 1.25rem;
}

.tw-toc-list li {
  margin: 0.25rem 0;
}

.tw-toc-sub {
  margin-left: 1rem;
  list-style-type: circle;
}

.tw-toc a {
  text-decoration: none;
  color: inherit;
}

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

Step 3 (Optional): Smooth Scrolling to Anchors

Add this once, site-wide, so clicking a table of contents link scrolls smoothly instead of jumping instantly:

html {
  scroll-behavior: smooth;
}

If you'd rather control scroll offset (useful if you have a sticky header that would otherwise cover the heading right after jumping), a small JavaScript scroll-margin-top rule is more reliable than relying on scroll-behavior alone:

h2,
h3 {
  scroll-margin-top: 80px; /* match your sticky header's height */
}

When a Table of Contents Isn't Worth Adding

Not every post benefits from one. A 400-word news update or a short opinion piece doesn't need a jump-to-section list, and forcing one in adds visual clutter without helping the reader. The count( $matches ) < 3 check in Step 1 already handles this automatically for the custom version; if you're using a plugin, most (including both mentioned above) offer a similar "minimum headings" setting worth configuring rather than leaving at its default.

Choosing Between the Two Approaches

  • Use LuckyWP Table of Contents or Easy Table of Contents if you want configurable styling themes, per-post enable/disable toggles, and a settings screen without touching code.
  • Build it yourself if you want the exact markup and styling shown above, or you're already comfortable extending the_content filter and don't want another plugin running purely for a list of links.
  • Either approach pairs naturally with a longer guide, the same kind of content covered in what WordPress plugins are and how they work, where a reader often wants to jump straight to one specific section.

Frequently Asked Questions (FAQ) About WordPress Tables of Contents

It can indirectly help by improving how long readers stay on the page and by making your content structure clearer to search engines through properly nested headings and anchor links. In some cases, well-structured jump links have also been shown as expandable sitelinks directly in Google search results, though that display isn't guaranteed for every site.

Yes, with both the custom the_content filter approach and any decent plugin, the table of contents is generated fresh from your post's current headings every time the page renders. There's no separate list to remember to update after editing a heading's wording.

This happens when two headings have identical text, which sanitize_title() will turn into the exact same slug. The custom function above handles this automatically by appending -2, -3, and so on to duplicates; if you're using a plugin and see this issue, check whether it has a similar uniqueness safeguard or rename one of the duplicate headings.

Yes, in the custom implementation, change the regex character class from [2-3] to [2-4] in both places it appears, and add a corresponding CSS indent class for the new level. Most plugins expose this as a simple checkbox in their settings instead.

It's a reasonable addition for longer posts, since an expanded ten-item list can push the actual article content well below the fold on a small screen.

For the custom version, check that your post actually has at least three h2 or h3 headings, since the function intentionally skips shorter posts. Also confirm the filter is hooked to the_content and not accidentally stripped by a page builder that bypasses that filter entirely, which some page builders do for their own custom content areas.

Yes. With the custom function, change the final preg_replace() insertion point, or better, register a shortcode that returns the same $toc_html and place [table_of_contents] wherever you want it in the post, similar to the shortcode pattern used throughout this site.

Conclusion

A table of contents is a small addition with a real, measurable payoff on long-form content: readers get to their answer faster, and posts with clear heading structure have a better shot at appearing with expandable jump links in search results. LuckyWP Table of Contents or Easy Table of Contents get this running in minutes with a settings screen and no code.

The custom the_content filter approach above is a complete, working alternative that generates unique anchor IDs, preserves any existing ones, and skips itself entirely on short posts where a table of contents wouldn't help anyone. It's also a useful pattern to understand on its own, since regex-based content transformation through the_content filter is the same general technique behind a number of other WordPress content features, from automatic internal linking to reading-time estimates.

Whichever approach you choose, test it on your longest and shortest posts both. A table of contents that works beautifully on a 3,000-word guide but breaks or looks silly on a 300-word post isn't actually finished yet.

Tags :
Share :

Related Posts

Effortlessly Crafting Compelling WordPress Pages

Effortlessly Crafting Compelling WordPress Pages

As a website owner or content creator, having the ability to seamlessly add new pages to your WordPress site is crucial. Whether you're introducing a

Continue Reading
High Traffic Tips for WordPress Mastery 🚥

High Traffic Tips for WordPress Mastery 🚥

In our digital age, where online visibility is paramount, ensuring your WordPress site can handle surging traffic is crucial. Just like a finely-tune

Continue Reading
How Do I Change the WordPress Login URL?

How Do I Change the WordPress Login URL?

By default, every WordPress site's login page lives at the same predictable address: yoursite.com/wp-login.php (which also happens to redirect from

Continue Reading