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:
- On single posts only, it grabs every
<h2>in the content. - It turns each heading into a slug (
toc-why-it-matters) usingsanitize_title(). - It builds a list of links at the top and adds the matching
idto 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:
- Only H2s. No nested H3s. For most blog posts that's enough. If you need nesting, extend the regex to
h[2-3]and indent the H3s in the list. - Duplicate headings. Two H2s with the same text get the same id, so both links go to the first one. Rare, but it happens with headings like "Example".
- Existing ids get replaced. If a heading already has an
id(some page builders add them), this overwrites it.
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.