How to build an HTML table of contents

A table of contents is a list of links to heading ids on the same page. The markup is trivial; what breaks is the heading landing under a sticky header, and links that stop matching after an edit.

An HTML table of contents is a list of links pointing at id values on the headings of the same page, and the browser handles the jumping with no script.

<nav aria-label="On this page">
  <ol>
    <li><a href="#scope">Scope</a></li>
    <li><a href="#pricing">Pricing</a></li>
  </ol>
</nav>

<h2 id="scope">Scope</h2>

That is the working version. Everything below is about the four things that go wrong afterwards.

A long document with a table of contents in a sidebar, the current section highlighted.
A long document with a table of contents in a sidebar, the current section highlighted.

Write the ids by hand

Generated ids are convenient and fragile. Reword a heading and the slug changes, which breaks every link pointing at it, including bookmarks you never see.

Keep ids short, lowercase, hyphenated, and tied to the topic rather than the exact wording. #pricing survives a heading changing from "Pricing" to "What it costs". #what-it-costs does not.

Ids must be unique in the document. Two headings with the same id means the browser jumps to the first one and the second is unreachable.

Fix the sticky header collision

The most common complaint about anchor links is that the heading lands under the fixed bar at the top of the page.

One CSS property solves it.

h2, h3 { scroll-margin-top: 84px; }

Set the value to the header height plus a little breathing room. The browser now stops that far short, and the heading is visible.

The same property fixes it for keyboard focus and for the browser restoring a position on reload. No script needed, which is why any JavaScript scroll offset you find is worth deleting.

Add scroll-behavior: smooth on the root if you want the animated scroll, and pair it with a prefers-reduced-motion guard.

Sticky on desktop, collapsed on phones

A sidebar table of contents that scrolls away is half useless on a long document.

.toc { position: sticky; top: 100px; max-height: 80vh; overflow: auto; }

On a phone there is no room for a sidebar. Put the same list inside a <details> element at the top of the page, closed by default, so it is one tap and costs three lines of height.

Switch between the two with a media query at whatever width your content column stops being comfortable.

Which HTML table of contents fits which document

Document Table of contents shape
Under about 800 words None. It is overhead
A long guide, read top to bottom Inline list after the intro
Reference material, read by lookup Sticky sidebar
A spec with numbered clauses Nested list, numbers preserved
Read mostly on phones Collapsed details element
Printed and circulated Inline list plus a print rule hiding the links

The first row matters. A five-item table of contents above a short page pushes the actual content below the fold and helps nobody.

Highlighting the current section

Marking where the reader is takes a little script, and it is the only part of this that does.

An IntersectionObserver watching the headings is the cheap version. When a heading crosses the top of the viewport, add a class to the matching link and remove it from the others.

Mark the active link with aria-current="location" as well as a colour, so the state is announced rather than only shown.

If you would rather avoid script entirely, skip this. A table of contents without a current marker is still perfectly usable.

The same table of contents on a phone, collapsed into a details element at the top of the page.
The same table of contents on a phone, collapsed into a details element at the top of the page.

Numbering, nesting, and depth

Use <ol> when the document has real section numbers, <ul> when it does not. Do not fake numbers with text if the list can produce them.

Nest at most two levels. A three-level table of contents is usually a sign the headings themselves need reorganising.

Nest by wrapping the child list inside the parent <li>, not as a sibling. That keeps the structure correct for semantic HTML and for assistive technology.

A printed page cannot be clicked, so a table of contents in print should either carry page numbers or be hidden.

Page numbers need a print stylesheet with generated content, which not every browser supports well. Hiding the list is the safer default for a document people print occasionally. A print stylesheet covers the wider set of rules worth adding.

For search engines, a table of contents on a long page sometimes produces jump links directly in the result. Nothing special is required for that beyond stable ids and headings that describe their sections accurately.

Descriptive headings are the part you control. "Refund window" earns a jump link; "More detail" does not.

Once the page is at an address, the hash becomes a shareable position.

  1. Open your file in the HTML file opener to confirm it renders outside your folder.
  2. Paste the HTML into a NOS document. The anchors and the sticky sidebar render as written.
  3. Share, then Share link, then Create link.
  4. To point someone at one section, append the heading id to the link with a hash.
The share dialog with the link created, ready to have a section hash appended.
The share dialog with the link created, ready to have a section hash appended.

That last step is what people actually want. Sending a fifty-page onboarding document is less useful than sending the line of it that answers the question.

The address does not move when you edit, so those section links keep working. This is the same argument as in onboarding docs in HTML and product specs in HTML.

A browser address bar showing the share link with a section hash appended.
A browser address bar showing the share link with a section hash appended.

Before you publish it

  • Is every id unique, and written by hand rather than generated?
  • Does clicking each entry land the heading clear of the sticky header?
  • Does the list collapse to something usable on a phone?
  • Does every entry match a heading that still exists?
  • Is there a <title>, and does the page need to stay unlisted?

Questions people ask

How do I link to a heading on the same page?

Give the heading an id, then link to that id with a hash. A heading with id="pricing" is reached by href="#pricing". No JavaScript is involved, and the browser also updates the address bar so the position can be shared.

Why does my anchor link land under the sticky header?

The browser scrolls the heading to the very top of the viewport, where the fixed header covers it. Add scroll-margin-top to the headings, set to the header height. That is one CSS line and it replaces every JavaScript workaround.

Should heading ids be generated or written by hand?

Write them by hand for anything you will link to from elsewhere. Generated slugs change when you reword the heading, which silently breaks every existing link to it, including links other people have bookmarked.

How do I share a long HTML document with a table of contents?

Paste the HTML into a NOS document and send the share link. Anchor links keep working at that address, so you can send a link that opens directly at a specific section by appending the hash.

Keep reading