Table of Contents — Free HTML CSS JS Snippet

Table of Contents · Navigation · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Auto-generates TOC from all h2 and h3 elements in the article
Auto-id generation for headings without ids: lowercase + hyphenate + strip special chars
IntersectionObserver rootMargin: active = heading in top 20% of viewport
h3 indentation: .h3 class adds padding-left and smaller font-size
Smooth scroll: e.preventDefault() + scrollIntoView({behavior:"smooth"})
Active link: indigo text + 2px left border via .active class
Sticky sidebar: position:sticky top:20px — stays visible during scroll
observer.observe() on each heading after TOC is built

About this UI Snippet

Table of Contents — Auto-Generated from Headings, IntersectionObserver Active Highlight & Sticky Sidebar

Screenshot of the Table of Contents snippet rendered live

Land on a 3,000-word guide with no table of contents and the only way to know what's ahead — or to get back to the part about installation — is to scroll and hope. A sidebar that lists every section, jumps to it on click, and quietly tracks which one you're currently reading turns that wall of text into something closer to a map. This snippet builds that sidebar entirely from the article's own markup: it walks the DOM for h2/h3 elements, builds the link list automatically, highlights whichever section currently fills the reader's view using IntersectionObserver, smooth-scrolls on click, and stays pinned in view via position: sticky as the page scrolls beneath it.

Building the list from the headings that already exist

Rather than asking authors to maintain a parallel list of links — the kind that quietly drifts out of sync the moment someone adds a section and forgets the sidebar — the script queries article.querySelectorAll('h2, h3') and builds one <li> per heading it finds. Any heading missing an id gets one generated on the spot: h.textContent.trim().toLowerCase().replace(/\s+/g,'-').replace(/[^a-z0-9-]/g,''), lowercasing, hyphenating spaces, and stripping anything that wouldn't survive in a URL fragment. The practical result is that *any* article with headings gets a working, always-current TOC the moment this script runs against it — no manual bookkeeping required.

Why the active zone sits near the top of the viewport, not the middle

A naive "50% visible = active" rule sounds reasonable until you notice that long sections dominate the screen for most of the scroll, while short ones flicker active and inactive within a single swipe. This snippet instead uses rootMargin: '-10% 0% -70% 0%', which shrinks the observer's effective viewport down to a thin horizontal band occupying roughly the top fifth of the screen. A heading is "active" the instant it crosses into *that* band — which corresponds almost exactly to where a reader's eyes are actually resting as they scroll downward. It's the same top-band philosophy the Scroll-Spy Navigation snippet uses for its sliding indicator, just applied here to a flat list of links instead of a pill that glides between them.

Two heading levels, one indentation rule

The visual hierarchy between h2 and h3 entries comes from a single conditional class: links built from an H3 element get .h3 appended, which adds padding-left: 18px and drops the font size slightly. No nested <ul> elements, no recursive tree-building — just one class name deciding how far an item sits from the left edge, which keeps the markup flat and the click-to-scroll wiring identical for every link regardless of its level.

Sticking around without JavaScript scroll handlers

The sidebar uses position: sticky; top: 20px, a single CSS declaration that keeps it pinned near the top of the viewport as the surrounding page scrolls — without a single scroll event listener computing offsets on every frame. Pair that with scrollIntoView({ behavior: 'smooth', block: 'start' }) on each link's click handler (after e.preventDefault() cancels the browser's instant jump), and the whole navigation experience — sticky position, smooth travel, live highlight — runs almost entirely on browser-native primitives rather than hand-rolled scroll math. For pages where readers expand and collapse long sections rather than scrolling through everything, the Read More Expand snippet pairs naturally with this pattern.

Build with AI

Build, Understand, Optimize, and Extend It With AI

You don't have to work out the rootMargin math by hand. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why the observer's rootMargin of -10% top and -70% bottom shrinks the detection zone to a thin band near the top of the viewport, and how the visible Set combined with headings.find picks exactly one active heading even when several technically intersect at once. The same assistant can help optimize it — for instance whether rebuilding the auto-generated ids with a regex on every load is safe against duplicate heading text, or whether observing every heading individually scales fine on an article with hundreds of sections. It's also useful for extending the component: ask it to add a collapsible mobile version, support h4 nesting with deeper indentation, or highlight reading progress within the active section instead of just marking one link active. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build an auto-generating "table of contents" sidebar in plain HTML, CSS, and JavaScript using only IntersectionObserver — no scroll-position math, no framework.

Requirements:
- On load, query the article container for all h2 and h3 elements (in document order) and build one table-of-contents link per heading found — do not hand-author a parallel list of links anywhere in the markup.
- Any heading missing an id attribute must have one generated automatically from its text content: lowercased, whitespace collapsed to hyphens, and any character that isn't a lowercase letter, digit, or hyphen stripped out.
- Give h3-derived links an extra CSS class that adds left indentation and a smaller font size compared to h2-derived links, using a flat list (no nested unordered lists).
- Clicking a table-of-contents link must prevent the default instant jump and instead smooth-scroll the matching heading into view using scrollIntoView with smooth behavior.
- Create exactly one IntersectionObserver (not one per heading) that observes every heading, with a rootMargin that shrinks the effective viewport to roughly the top 20% of the screen (e.g. a large negative bottom margin and a smaller negative top margin) — a heading is "active" only once it crosses into that band.
- Maintain the current set of intersecting headings and always pick the one that comes first in document order among those currently visible as the single active link, removing the active class from every other link.
- The sidebar itself must use position: sticky to stay pinned near the top of the viewport as the user scrolls, and must collapse or hide entirely below a chosen mobile breakpoint via a media query.

Step by step

How to Use

  1. 1
    Scroll the article to see active section highlightingAs you scroll, the TOC link for the currently visible heading turns indigo and gets a left border. Scroll through the article to see it track your reading position.
  2. 2
    Click any TOC link to jump to that sectionClicking a TOC link smoothly scrolls the article to that heading using scrollIntoView. The active highlight updates to the section you jumped to.
  3. 3
    Replace the article content with your ownUpdate the .article HTML with your real content. Keep the h2 and h3 elements with meaningful ids (or let the script auto-generate them from heading text). The TOC regenerates automatically from the heading structure.
  4. 4
    Adjust the active detection zoneUpdate the IntersectionObserver rootMargin: "-10% 0% -70% 0%" to change when sections become "active". Increase the top margin to activate later in the scroll; decrease the bottom margin to keep the active section visible longer.
  5. 5
    Customise h2 and h3 visual hierarchyEdit .toc-link.h3 padding-left and font-size for the indentation level. Add .h4 class support by updating the querySelectorAll to include h4 and adding the .h4 CSS class.
  6. 6
    Export in your formatClick "HTML" for a standalone file, "JSX" for a React component using useEffect and useRef, or "Tailwind" for a React + Tailwind CSS version.

Real-world uses

Common Use Cases

Technical documentation and API reference pages
Long API documentation pages need a TOC for navigation. The auto-generation means adding new sections automatically appears in the TOC without any maintenance. The active highlight shows exactly which API method is currently in view.
Blog posts and long-form editorial content
Long-form blog posts benefit from TOC sidebars that give readers an overview and let them jump to the sections most relevant to them. The sticky sidebar stays accessible throughout the entire scroll.
Course content and tutorial pages
Learning platform course pages with step-by-step tutorials use TOCs to show the learning structure and let students jump to specific steps or concepts. The active highlight shows progress through the lesson.
Legal documents and terms of service pages
Legal documents are notoriously long and hard to navigate. A TOC with section anchors lets users jump to the specific clauses relevant to them. The active highlight confirms they reached the right section.
Study IntersectionObserver for scroll-based UI
The TOC demonstrates IntersectionObserver with a custom rootMargin for partial viewport detection. The "-10% 0% -70% 0%" margin creates a detection zone in the upper portion of the viewport.
Product changelog and release notes pages
Changelog pages list releases with h2 headers for each version. The TOC provides quick access to specific versions without scrolling. Users can share a link with the hash to point to a specific release.

Got questions?

Frequently Asked Questions

The observer uses rootMargin: "-10% 0% -70% 0%". This shrinks the root viewport: 10% from the top and 70% from the bottom, creating a detection zone that is only the top 20% of the viewport. When a heading enters this zone (moves to the top of the screen as the user scrolls), its TOC link becomes active. When the next heading enters the zone, the first one exits and becomes inactive. This creates a clean single-active-section highlight that tracks scroll position precisely.

The auto-generation works on any page with h2/h3 elements. Add the TOC container HTML (<nav class="toc" id="toc">) to your page template. Include the JavaScript after your article content. The script automatically finds all headings in #article and builds the TOC. For a CMS, add the TOC HTML to your layout template and include the script in your base JavaScript bundle.

Add a toggle button: <button class="toc-toggle" onclick="toggleTOC()">Contents</button>. Style it to appear only on mobile: @media (max-width: 700px) { .toc-toggle { display: block; } }. In toggleTOC(): document.getElementById("toc-list").classList.toggle("hidden"). Add .hidden { display: none } to CSS. The sticky sidebar remains visible on desktop; on mobile it becomes a collapsible panel.

Click "JSX" to download. Use useRef(null) for the article element. Run the heading collection and TOC building in useEffect([]) after mount. For the IntersectionObserver, also create it in useEffect and return a cleanup function: return () => observer.disconnect(). For Next.js App Router, use "use client" on the component since it uses DOM APIs and useEffect.