how-to · · 8 min read
how to add a table of contents to wordpress posts
Add a table of contents to WordPress posts: by hand with heading anchors, or automatically with a small plugin that links every H2 and H3. Full code inside.
By Big Bad Agency
A long guide without a table of contents makes the reader scroll to find out if the one answer they came for is even in there. Most of them won’t. They’ll leave and pick the next result.
A table of contents fixes that in two ways. The reader sees the whole post at a glance and jumps straight to their section. And every heading gets its own address (/your-post/#shipping-costs), which you can link to from anywhere, including your own other posts.
There are two ways to add one in WordPress. One takes five minutes and suits a single post. The other is a small plugin that does it for every post, which we’ve written out in full below.
first, check what you already have
Open the block inserter in a post and search for “table of contents”. If your theme or a plugin you already run provides a block for it, try that first. One less thing to maintain is worth more than anything below.
If nothing comes up, read on.
the hand-made way: anchors and a list
This works in the block editor with nothing installed, and it’s the right choice for one long post.
1. Give each heading an anchor. Click a heading, open the block settings sidebar, expand Advanced and fill in HTML anchor: something short, lowercase, with hyphens, like shipping-costs. That becomes the heading’s id.
2. Add a list near the top with one line per section.
3. Link each line to its anchor. Select the text, add a link, and type # followed by the anchor, as in #shipping-costs. The # tells the browser to jump to that spot on the same page instead of loading another one.
That’s it. It’s exact, it’s yours, and nothing can break it. The catch is that you maintain it by hand: rename a section and you’re fixing two places, and add one and you’ll forget the list. That’s fine for one cornerstone guide. It gets tiring across fifty posts.
the automatic way: a small plugin
This version reads each post’s H2 and H3 headings as the page is shown, gives every one an id, and puts an “On this page” list just above the first heading, so your opening paragraph still comes first. It only runs on single posts with three or more headings. A short post doesn’t need a list, and a list with two lines in it looks like a mistake.
It goes in its own small plugin, not your theme. If you haven’t set one up before, how to add custom PHP to WordPress covers it in five minutes. The full file is at the end. Here’s what each part does.
1. run only where it belongs
add_filter( 'the_content', 'bb_toc_add', 20 );
function bb_toc_add( $content ) {
if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
return $content;
}
the_content runs everywhere a post’s text is printed, including archive pages, feeds, widgets and related-post blocks. These three checks keep it to the main body of a single post. Change 'post' to array( 'post', 'page' ) if you want it on pages too.
Priority 20 runs it after WordPress has turned blocks and paragraphs into HTML, so it’s reading the same headings the reader sees.
2. keep the ids that already exist
$used = array();
if ( preg_match_all( '/\sid\s*=\s*(["\'])(.*?)\1/i', $content, $existing ) ) {
foreach ( $existing[2] as $id ) {
$used[ $id ] = true;
}
}
If you set an HTML anchor by hand, or something else in the post already uses an id, we keep it and never generate the same one. Two elements with the same id on one page means the link jumps to the wrong one.
3. give every heading an id, and collect the list
$items = array();
$with_ids = preg_replace_callback(
'/<h([23])(\s[^>]*)?>(.*?)<\/h\1>/is',
function ( $h ) use ( &$items, &$used ) {
$level = (int) $h[1];
$attrs = isset( $h[2] ) ? $h[2] : '';
$text = trim( wp_strip_all_tags( $h[3] ) );
if ( '' === $text ) {
return $h[0];
}
if ( preg_match( '/\sid\s*=\s*(["\'])(.*?)\1/i', $attrs, $m ) ) {
$id = $m[2];
$out = $h[0];
} else {
$base = sanitize_title( $text );
if ( '' === $base ) {
$base = 'section';
}
$id = $base;
$n = 2;
while ( isset( $used[ $id ] ) ) {
$id = $base . '-' . $n++;
}
$used[ $id ] = true;
$out = '<h' . $level . $attrs . ' id="' . esc_attr( $id ) . '">' . $h[3] . '</h' . $level . '>';
}
$items[] = array( $level, $id, $text );
return $out;
},
$content
);
sanitize_title() is the same function WordPress uses to build post slugs, so “Shipping costs & returns” becomes shipping-costs-returns. Two headings with the same words get -2, -3 and so on, so every link still lands on its own heading.
The heading’s own markup is left alone, so bold text or a link inside a heading still looks the way you wrote it. Only the plain text goes into the list.
We use a callback rather than a plain find-and-replace for a reason. A heading containing a dollar sign, like “Costs $1 and up”, would be mangled by preg_replace, which reads $1 as an instruction.
4. build the list and put it above the first heading
if ( null === $with_ids || count( $items ) < bb_toc_min_headings() ) {
return $content;
}
$list = '<nav class="bb-toc" aria-label="' . esc_attr__( 'Table of contents', 'your-textdomain' ) . '">'
. '<p class="bb-toc__title">' . esc_html__( 'On this page', 'your-textdomain' ) . '</p><ol>';
foreach ( $items as $item ) {
$list .= '<li class="bb-toc__h' . $item[0] . '"><a href="#' . esc_attr( $item[1] ) . '">' . esc_html( $item[2] ) . '</a></li>';
}
$list .= '</ol></nav>';
if ( preg_match( '/<h[23][\s>]/i', $with_ids, $first, PREG_OFFSET_CAPTURE ) ) {
return substr_replace( $with_ids, $list, $first[0][1], 0 );
}
return $list . $with_ids;
}
The list is a <nav> with a label, so screen readers announce it as the page’s table of contents rather than as one more list. H3 entries get their own class, and the styling below indents them under their H2.
5. a little styling, and the sticky-header fix
add_action( 'wp_head', function () {
if ( ! is_singular( 'post' ) ) {
return;
}
echo '<style>.bb-toc{margin:1.5em 0;padding:1em 1.25em;border-left:3px solid currentColor}'
. '.bb-toc__title{font-weight:700;margin:0 0 .5em}.bb-toc ol{margin:0;padding-left:1.25em}'
. '.bb-toc__h3{margin-left:1.25em;list-style-type:circle}'
. '.entry-content :is(h2,h3)[id]{scroll-margin-top:5rem}</style>';
} );
The last rule is the one people miss. If your theme has a header that stays stuck to the top of the screen, a jump link scrolls the heading right up underneath it, hidden. scroll-margin-top leaves a gap above the heading when a link lands on it. Raise 5rem if your header is taller.
.entry-content is the wrapper most themes put around a post’s body. If yours uses a different class, the heading rule won’t match. Check with your browser’s inspector.
what it doesn’t do
- It doesn’t nest H4 and below. H2 and H3 cover almost every post. A list that goes four levels deep is longer than the reader’s patience.
- It doesn’t touch headings inside other blocks’ HTML that never reach
the_content, such as a heading typed into a widget. - It doesn’t collapse, stick or follow you down the page. It’s a plain list at the top. That’s a deliberate choice: it works on every theme, with no JavaScript.
- It doesn’t change your post in the database. The ids are added as the page is shown. Switch the plugin off and every post is exactly as you wrote it. That also means the anchors only exist while the plugin is on. If you link to
#shipping-costsfrom elsewhere, those links will scroll nowhere once it’s off.
That last point is the real choice between the two methods. Hand-set anchors live in the post forever. Generated ones live in the plugin.
test it on one post first
Open a long post and check four things. The list appears above the first heading. Every link jumps to its heading. The heading isn’t hidden under a sticky header. A short post shows no list at all. Then view the page source and search for one of the ids, to make sure it’s in the HTML the server sends and not something only a script adds.
the whole file
Save this as wp-content/plugins/post-table-of-contents/post-table-of-contents.php and activate Post Table of Contents on the Plugins screen.
<?php
/**
* Plugin Name: Post Table of Contents
* Description: Adds an "On this page" list of a post's H2 and H3 headings above the first heading.
* Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;
// Only single posts with at least this many headings get a list.
function bb_toc_min_headings() {
return 3;
}
add_filter( 'the_content', 'bb_toc_add', 20 );
function bb_toc_add( $content ) {
if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
return $content;
}
$used = array();
if ( preg_match_all( '/\sid\s*=\s*(["\'])(.*?)\1/i', $content, $existing ) ) {
foreach ( $existing[2] as $id ) {
$used[ $id ] = true;
}
}
$items = array();
$with_ids = preg_replace_callback(
'/<h([23])(\s[^>]*)?>(.*?)<\/h\1>/is',
function ( $h ) use ( &$items, &$used ) {
$level = (int) $h[1];
$attrs = isset( $h[2] ) ? $h[2] : '';
$text = trim( wp_strip_all_tags( $h[3] ) );
if ( '' === $text ) {
return $h[0];
}
if ( preg_match( '/\sid\s*=\s*(["\'])(.*?)\1/i', $attrs, $m ) ) {
$id = $m[2];
$out = $h[0];
} else {
$base = sanitize_title( $text );
if ( '' === $base ) {
$base = 'section';
}
$id = $base;
$n = 2;
while ( isset( $used[ $id ] ) ) {
$id = $base . '-' . $n++;
}
$used[ $id ] = true;
$out = '<h' . $level . $attrs . ' id="' . esc_attr( $id ) . '">' . $h[3] . '</h' . $level . '>';
}
$items[] = array( $level, $id, $text );
return $out;
},
$content
);
if ( null === $with_ids || count( $items ) < bb_toc_min_headings() ) {
return $content;
}
$list = '<nav class="bb-toc" aria-label="' . esc_attr__( 'Table of contents', 'your-textdomain' ) . '">'
. '<p class="bb-toc__title">' . esc_html__( 'On this page', 'your-textdomain' ) . '</p><ol>';
foreach ( $items as $item ) {
$list .= '<li class="bb-toc__h' . $item[0] . '"><a href="#' . esc_attr( $item[1] ) . '">' . esc_html( $item[2] ) . '</a></li>';
}
$list .= '</ol></nav>';
if ( preg_match( '/<h[23][\s>]/i', $with_ids, $first, PREG_OFFSET_CAPTURE ) ) {
return substr_replace( $with_ids, $list, $first[0][1], 0 );
}
return $list . $with_ids;
}
add_action( 'wp_head', function () {
if ( ! is_singular( 'post' ) ) {
return;
}
echo '<style>.bb-toc{margin:1.5em 0;padding:1em 1.25em;border-left:3px solid currentColor}'
. '.bb-toc__title{font-weight:700;margin:0 0 .5em}.bb-toc ol{margin:0;padding-left:1.25em}'
. '.bb-toc__h3{margin-left:1.25em;list-style-type:circle}'
. '.entry-content :is(h2,h3)[id]{scroll-margin-top:5rem}</style>';
} );
If you need more than this, such as a floating list that follows the reader, per-post settings, or picking which heading levels count from a screen instead of in code, that’s when a dedicated table of contents plugin earns its place. For a list at the top of every long post, under 90 lines you own will do.