Table of Contents Block and Shortcode

List a post's headings as links with the Table of Contents block or the [hydrogen_seo_toc] shortcode. Anchors are added for you, with no front-end JavaScript.

8 min read
On this page
  1. Add it to a post
  2. In the block editor
  3. With the shortcode
  4. Options
  5. Leave a heading out
  6. How the list is built
  7. How the anchors are made
  8. The markup and its styles
  9. Where nothing is printed
  10. Limits
  11. Developer filters
  12. hydrogen_seo_toc_headings
  13. hydrogen_seo_toc_html
  14. hydrogen_seo_toc_inline_css
  15. hydrogen_seo_toc_max_entries
  16. hydrogen_seo_toc_reserved_ids
  17. Performance
  18. FAQ
  19. Does the block add anchors to headings on every post?
  20. Can I add a table of contents to every post automatically?
  21. Why is a heading missing from the list?
  22. Why does the published list have more entries than the editor preview?
  23. Will a table of contents get me section links in Google?
  24. Does it work with the Classic editor and page builders?
  25. Related reading

A table of contents is a list of links at the top of a post, one per heading, that lets a reader see what the page covers and jump to the part they came for. From version 0.0.31, Hydrogen SEO has a Table of Contents block and a [hydrogen_seo_toc] shortcode that build that list from the post's own headings and give each heading an anchor to link to. There are no settings to configure and nothing is stored: a post gets a table of contents when you add the block or shortcode to it, and no other page on the site changes. It is free, needs no account, and loads no JavaScript on the front end.

Add it to a post

In the block editor

  1. Place the cursor where the list should go. After the introduction and before the first heading is the usual place.
  2. Click +, search for Table of Contents and insert it. The block is in the Design category; typing /toc or /contents finds it too.
  3. The block shows a preview built from the headings already in the post. If there are none yet, it reads No headings yet. Headings you add to this post are listed here.
  4. Update or publish the post.

Only a short block comment is saved in the post. The list itself is built when the page is shown, so it follows the headings as you edit them and never needs regenerating.

With the shortcode

In the Classic editor, a Shortcode block or a page builder's shortcode element, add:

text
[hydrogen_seo_toc]

The shortcode and the block produce the same markup and take the same three options.

Options

In the block, the options are in the Settings panel of the sidebar. In the shortcode they are attributes.

Block settingShortcode attributeDefaultWhat it does
TitletitleTable of contentsThe text above the list. Leave it empty (title="") to show the list without a title
Heading levelslevels2,3Which headings are listed, from H2 to H6. The shortcode accepts a list (2,3,4), a range (2-4) or the same with an h (h2 h3). In the block, tick the levels; the last one left cannot be unticked
List stylelist_stylenumberednumbered prints an ordered list, bulleted an unordered one

Examples:

text
[hydrogen_seo_toc title="In this guide" levels="2-4"]
[hydrogen_seo_toc title="" levels="2" list_style="bulleted"]

A value the feature does not recognise is ignored and the default is used: levels="1" or levels="7" lists H2 and H3, and a list style other than the two above prints a numbered list. H1 is never listed, because it is the page title.

Leave a heading out

Give the heading the CSS class hydrogen-seo-toc-exclude. In the block editor, select the Heading block, open Advanced in the sidebar and type the class into Additional CSS class(es). In HTML:

html
<h2 class="hydrogen-seo-toc-exclude">Share this post</h2>

An excluded heading is left out of every table of contents on the page and gets no anchor added.

How the list is built

The list is built from the finished HTML of the post, after blocks and shortcodes have run. That means headings printed by other blocks and shortcodes are included, not only the ones you typed.

  • Order and nesting. Headings appear in page order. Each one nests under the nearest heading above it with a lower level, so an H3 sits under the H2 before it. A skipped level, an H4 directly under an H2 for example, still goes exactly one list deeper.
  • Label. The link text is the heading's text with tags removed. A heading with no text is skipped.
  • Size. A table of contents lists at most 100 headings.
  • Empty posts. If no heading matches, nothing is printed at all, not even the title.

How the anchors are made

A link such as #brewing-ratio needs a heading with id="brewing-ratio" to land on.

  • A heading that already has an id keeps it. If you set an HTML anchor on a Heading block (under Advanced), the table of contents links to that.
  • A listed heading without an id gets one made from its text: accents are folded and Latin letters lowercased, letters and digits of every script are kept, and everything else becomes a hyphen. "Brewing ratio: 1 to 16" becomes brewing-ratio-1-to-16. Ids are cut at 80 characters, and a heading with no letters or digits gets section.
  • Duplicates get a number. A second "Summary" becomes summary-2, a third summary-3.
  • comments and respond are never used, because WordPress uses them for the comment list and form. A heading "Comments" gets comments-2.
  • Only listed headings are touched. A heading whose level is not in Heading levels, or that carries the exclude class, is left exactly as written. Posts without the block or shortcode are not parsed at all.

When a page shows several posts in full, such as a blog index, each post's headings get ids that no other post on the page uses, so every table of contents links to its own post. On the post's own page, its headings always get the ids they would get alone, so a link to your-post/#brewing-ratio shared elsewhere keeps working.

The markup and its styles

html
<nav class="hydrogen-seo-toc" aria-label="Table of contents">
  <p class="hydrogen-seo-toc__title">Table of contents</p>
  <ol class="hydrogen-seo-toc__list">
    <li><a href="#choosing-beans">Choosing beans</a>
      <ol>
        <li><a href="#roast-level">Roast level</a></li>
      </ol>
    </li>
    <li><a href="#brewing-ratio">Brewing ratio</a></li>
  </ol>
</nav>

The nav element and its label tell assistive technology that this is a navigation block and which one.

Three short layout rules (a bottom margin, a bold title and list indentation) are printed inline, once, on pages that show a table of contents. No stylesheet is requested. Everything else, including fonts, colours and link styles, comes from your theme. To restyle it, target the classes above in your theme's CSS.

If your site has a sticky header, a heading can end up hidden under it after a jump. One rule in your theme fixes that:

css
h2, h3, h4 { scroll-margin-top: 80px; }

Where nothing is printed

  • Outside post content. In a widget, a template part or a theme file, the block and shortcode print nothing. Headings there cannot be given anchors, so there would be nothing to link to.
  • In feeds and excerpts. A feed reader or an automatic excerpt gets the post without the list.

On a post split into pages with the Page Break block (``), the table of contents lists the headings of the page it is on, since WordPress renders one page of the post at a time. Put the block on each page that needs one.

Limits

  • The editor preview only sees Heading blocks. The published list is built from the rendered page, so it also includes headings that come from shortcodes, reusable patterns rendered on the server or Custom HTML blocks. The preview in the editor is built from the post's Heading blocks alone, at any depth of nesting, so on such a post the published list can be longer than the preview.
  • Headings must be in the post content. The post title, headings in your theme's templates and headings a page builder prints outside the content area are not seen.
  • Generated anchors follow the heading text. Rename a heading and its generated id changes, which breaks links people saved to that section. For a section you expect to be linked to, set an HTML anchor on the heading; the table of contents uses it and it stays put.
  • No collapse button, sticky position or scroll highlighting. Those need JavaScript, and this feature ships none. The list is plain links.

Developer filters

hydrogen_seo_toc_headings

Filters the headings one table of contents lists. Arguments: $entries (array of arrays with id, label and level, in page order), $atts (array with title, levels and list_style) and $post (the current WP_Post, or null).

php
// Shorten long labels in the list; the headings themselves stay as written.
add_filter( 'hydrogen_seo_toc_headings', function ( $entries ) {
    foreach ( $entries as $i => $entry ) {
        $entries[ $i ]['label'] = wp_html_excerpt( $entry['label'], 60, '…' );
    }
    return $entries;
} );

hydrogen_seo_toc_html

Filters the finished markup. Arguments: $html (string, empty when there are no headings), $entries and $atts.

php
// Wrap the table of contents in a details element, closed by default.
add_filter( 'hydrogen_seo_toc_html', function ( $html, $entries ) {
    if ( '' === $html || count( $entries ) < 8 ) {
        return $html;
    }
    return '<details><summary>Contents</summary>' . $html . '</details>';
}, 10, 2 );

hydrogen_seo_toc_inline_css

Filters the inline CSS printed with a table of contents. One argument: $css (string). Return an empty string to print none.

php
// The theme styles the list itself.
add_filter( 'hydrogen_seo_toc_inline_css', '__return_empty_string' );

hydrogen_seo_toc_max_entries

Filters the most entries one table of contents lists. One argument: $max (int, default 100, at least 1).

php
add_filter( 'hydrogen_seo_toc_max_entries', function () {
    return 40;
} );

hydrogen_seo_toc_reserved_ids

Filters the ids no heading is given, because the theme or WordPress uses them elsewhere on the page. One argument: $ids (array of strings, default comments and respond).

php
// The theme has elements with these ids.
add_filter( 'hydrogen_seo_toc_reserved_ids', function ( $ids ) {
    return array_merge( $ids, [ 'main', 'sidebar', 'footer' ] );
} );

Performance

A post without the block or shortcode is returned as it came in, without being parsed. A post with one is read once, in a single pass over its HTML, when the page is built. No database query is added, nothing is saved, and the front end gets no script and no stylesheet request.

FAQ

Does the block add anchors to headings on every post?

No. Anchors are added only on posts that contain the block or shortcode, and there only to the headings the list links to.

Can I add a table of contents to every post automatically?

Not from a setting. The block or shortcode goes into each post that should have one, which keeps short posts free of a list they do not need. To have it ready in new posts, add the block to a pattern that you start posts from. The block has to end up inside the post content; placed in a theme template around the content it prints nothing.

Why is a heading missing from the list?

Check its level against Heading levels (H2 and H3 by default), check that it does not carry the hydrogen-seo-toc-exclude class, and check that it is inside the post content. Headings in the theme's templates are not seen.

Why does the published list have more entries than the editor preview?

The preview reads Heading blocks. The published list reads the rendered page, which can contain headings printed by shortcodes or other blocks.

It can help, and it is not something you can switch on. Google sometimes shows links to sections under a result when a page has clear sections with anchors, and decides that itself. The reliable benefit is to readers on long pages. See how to add a table of contents to WordPress posts.

Does it work with the Classic editor and page builders?

Yes, through the shortcode, as long as the headings are in the post content that WordPress filters.

Was this page helpful?
Still stuck? Contact support