search

Table of Contents for Posts : TOC Plugin for UX & SEO

The “Efficient TOC Widget” is a custom WordPress plugin designed to add a Table of Contents (TOC) to single posts on your WordPress blog. The plugin helps your readers quickly navigate through long-form content while improving the user experience on your site.

I currently use the plugin on this website (check out the Table of Contents in the sidebar, visible on desktop only).

TOC Plugin Overview

This TOC widget plugin dynamically generates a Table of Contents by capturing all <h2> headings within the post content. Each heading is given a unique ID and an anchor link, allowing users to jump to each section.

Adding a Table of Contents to your posts can enhance visibility in search engines. Google may recognize the TOC structure and display links to specific sections directly in the search engine results pages (SERPs), offering users quick navigation to relevant content.

This can improve click-through rates as users see clearly organized sections, which makes your content more accessible and appealing in search results. It looks like this in Google search results:

TOC in Google Search Results

Providing users with a Table of Contents improves user experience by making long content easier to navigate, which can also positively impact your SEO.

Using proper heading hierarchy in your posts makes TOC navigation even more effective. Let’s dive into the code and understand how it works…

The inspiration for this plugin came from Dorzki’s post on building a dynamic table of contents in WordPress.

Code Breakdown

To use this plugin, create a new PHP file (e.g., efficient-toc-widget.php), add the code to it, and save it in the wp-content/plugins folder. This will make the plugin available for activation in your WordPress dashboard.

Here’s the full code for the plugin:

<?php 
/**
* Plugin Name: Efficient TOC Widget
* Description: Add a table of contents widget to single posts.
* Version: 1.4
* Author: Roee Yossef
* Text Domain: efficient-toc-widget
* Domain Path: /languages
*/

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Exit if accessed directly.
}
class Efficient_TOC_Widget {

    private $headings  = [];
    private $counter   = 0;
    private $permalink = '';

    public function __construct() {
        add_action( 'XXXXXX', [ $this, 'show_toc' ] ); // Replace 'XXXXXX' with the desired action hook
        add_filter( 'the_content', [ $this, 'build_toc' ], 12 );
    }

    public function build_toc( $content ) {
        if ( ! is_singular( 'post' ) || ! in_the_loop() ) {
            return $content;
        }
        $this->counter   = 0;
        $this->headings  = [];
        $this->permalink = get_permalink();
        $content = preg_replace_callback( '/<h2[^>]*>(.*?)<\/h2>/i', [ $this, 'extract_headings' ], $content );

        return $content;
    }

    public function extract_headings( $matches ) {
        $id = 'title_' . $this->counter++;

        $this->headings[] = [
            'id'    => $id,
            'title' => strip_tags( $matches[1] )
        ];

        return sprintf(
            '<h2 id="%s" class="copy-heading" data-clipboard-text="%s">%s</h2>',
            esc_attr( $id ),
            esc_url( $this->permalink . '#' . $id ),
            wp_kses_post( $matches[1] )
        );
    }

    public function generate_toc() {
        if ( empty( $this->headings ) || count( $this->headings ) < 2 ) {
            return false; 
        }
    
        $html = '<div id="toc" class="toc_wrapper">';
        $title = esc_html__( 'Table of Contents', 'efficient-toc-widget' );
        $html .= '<p class="toc-title">' . $title . '</p>';
        $html .= '<ul>';
        $first = true;

        foreach ( $this->headings as $heading ) {
            $class = $first ? ' class="current"' : '';
        
            $html .= sprintf(
                '<li%s><a href="#%s">%s</a></li>',
                $class,
                esc_attr( $heading['id'] ),
                esc_html( $heading['title'] )
            );
            $first = false;
        }

        $html .= '</ul>';
        $html .= '</div>';

        return $html;
    }

    public function show_toc() {
        if ( is_singular( 'post' ) ) {
            global $post;

            if ( empty( $this->headings ) ) {
                $this->counter   = 0;
                $this->headings  = [];
                $this->permalink = get_permalink();
                preg_replace_callback( '/<h2[^>]*>(.*?)<\/h2>/i', [ $this, 'extract_headings' ], $post->post_content );
            }

            $toc = $this->generate_toc();
            if ( $toc ) {
                echo wp_kses_post( $toc );
            }
        }
    }
}

new Efficient_TOC_Widget();

How the Plugin Processes Headings

The build_toc() method hooks into the_content filter with priority 12. This ensures it runs after WordPress processes shortcodes (priority 11), so any headings generated by shortcodes are also captured.

The regex pattern /<h2[^>]*>(.*?)</h2>/i matches all <h2> headings regardless of existing attributes like classes or IDs that Gutenberg or other plugins may add. Without the [^>]* part, headings like <h2 class="wp-block-heading"> would be silently skipped.

Each heading gets a sequential anchor ID in the form title_0, title_1, etc. These IDs are used in the TOC links and in the data-clipboard-text attribute, so users can share direct links to specific sections.

The extract_headings() callback uses wp_kses_post() when reinserting heading content. This preserves any inline HTML (like <code> or <strong>) that may exist inside the heading, while still sanitizing against unsafe markup. For the TOC list items, strip_tags() is used instead to produce plain-text labels.

Each heading also receives a data-clipboard-text attribute containing the full URL with its anchor fragment. You can pair this with a clipboard library (like clipboard.js) to let users copy a direct link to any section by clicking the heading.

The the_content filter can fire multiple times per page load – SEO plugins, excerpt generators, and other components may trigger it. The in_the_loop() guard prevents the plugin from capturing headings from unintended contexts like sidebars or secondary queries. Note that show_toc() has its own fallback that extracts headings directly from $post->post_content, bypassing build_toc() and its guards – this is necessary because the action hook that renders the TOC may fire outside the main loop (e.g., in a sidebar).

Enqueuing Styles for the TOC

To style the Table of Contents, create a CSS file in your plugin folder and enqueue it properly using wp_enqueue_style(). You can add this function to the plugin file or hook it separately:

function efficient_toc_enqueue_styles() {
    if ( is_singular( 'post' ) ) {
        wp_enqueue_style(
            'efficient-toc-widget',
            plugin_dir_url( __FILE__ ) . 'css/toc-style.css',
            [],
            '1.4'
        );
    }
}
add_action( 'wp_enqueue_scripts', 'efficient_toc_enqueue_styles' );

The is_singular( 'post' ) check ensures the stylesheet loads only on single post pages, keeping your other pages lightweight.

By applying your own CSS, you can control elements like fonts, colors, spacing, and active-state highlighting to seamlessly match the TOC with your website’s design.

Choosing a Custom Hook for Displaying the TOC

Since the plugin uses the XXXXXX hook (a placeholder), you need to replace it with an actual action hook in your theme, depending on where you want the Table of Contents to appear. For instance, if you want it displayed at the end of a post, you could add a custom hook in your theme.

Adding a Custom Hook in Your Theme

In your theme’s single.php or a template file, you can create a hook like so:

do_action( 'custom_toc_hook' );

Then, in the plugin, replace XXXXXX with custom_toc_hook:

add_action( 'custom_toc_hook', [ $this, 'show_toc' ] );

By adding this line to your template, you control where the TOC will appear, giving you flexibility to integrate it into your site layout effectively.

Using custom hooks is a great way to add specific functionality precisely where you need it in your theme.

Adjusting TOC Display Based on Heading Count

The TOC will generate only if there are at least two <h2> headings. To change this threshold, modify the comparison value in generate_toc():

if ( empty( $this->headings ) || count( $this->headings ) < 2 ) {

By setting a different number, you can control when the TOC is displayed based on the minimum number of sections you want in the content.

FAQs

Common questions about the Efficient TOC Widget:

Can this plugin generate a TOC from H3 and H4 headings too?
Yes. Modify the regex in build_toc() to /<h[2-4][^>]*>(.*?)</h[2-4]>/i so it captures H2, H3, and H4 tags. You would also need to track the heading level and adjust the TOC HTML to render nested lists for sub-headings.
What does the data-clipboard-text attribute do?
It stores the full URL with the heading's anchor fragment (e.g., https://example.com/post/#code-breakdown). When paired with a clipboard library like clipboard.js, clicking a heading copies that direct link so users can share it easily.
Why does show_toc() extract headings directly instead of calling build_toc()?
build_toc() includes an in_the_loop() guard that causes it to bail out when called outside the main WordPress loop. Since show_toc() is typically hooked to a sidebar action that fires outside the loop, the fallback calls preg_replace_callback() directly to ensure headings are always extracted when needed.
How do I position the TOC on my page?
Replace the XXXXXX placeholder in the constructor with a custom action hook name (e.g., custom_toc_hook). Then place do_action( 'custom_toc_hook' ) in your theme's template file wherever you want the TOC to appear - in a sidebar, before the content, or at the end of the post.
Why does the filter use priority 12 instead of the default?
WordPress processes shortcodes via do_shortcode at priority 11 on the the_content filter. By using priority 12, the TOC plugin runs after shortcodes have been expanded, ensuring that any headings generated by shortcodes are included in the Table of Contents.

Conclusion

The “Efficient TOC Widget” plugin enhances post navigation and readability by offering a flexible, user-friendly Table of Contents. By using customizable hooks, proper the_content filter guards, and a sidebar-safe fallback in show_toc(), this plugin can be seamlessly integrated with any theme for improved usability and SEO.

Join the Discussion
0 Comments  ]

Leave a Comment

To add code, use the buttons below. For instance, click the PHP button to insert PHP code within the shortcode. If you notice any typos, please let us know!

Savvy WordPress Development official logo