Skip to main content

Table of Contents

Overview

The Table of Contents component automatically discovers headings in your page content, generates a clickable nested list of anchor links, and highlights the currently visible heading as the user scrolls. It requires no manual link management — drop the component on the page, configure which heading levels to include, and it builds itself at runtime.

The component is a single self-contained element (OmeTableOfContents). It is not a family — there are no child blocks to compose. All behavior is controlled through props on the root.

How It Works

  1. Template phase — The PHP builder renders a hidden template (<div data-ome-toc-template hidden>) containing a <ul>, <li>, and <a> skeleton. These are cloned at runtime to build the live list.
  2. Target discovery — The runtime reads targetSelector to find the content container. If empty or invalid, it falls back to main, article, [role='main'], .entry-content, .wp-block-post-content, then body. The TOC root itself is always excluded from heading scanning.
  3. Heading extraction — Headings from H2 through the configured depth are collected. Existing IDs are preserved; headings without IDs receive auto-generated slugs. Duplicate slugs are de-duplicated with numeric suffixes (e.g. repeated-heading-2).
  4. Tree building — Headings are organized into a nested <ul>/<li> tree that mirrors the heading hierarchy.
  5. Active tracking — An IntersectionObserver watches all collected headings. As the user scrolls, the first visible heading's corresponding link receives aria-current="location", and the item gets data-ome-active. The root H2 branch item receives data-ome-active-branch.
  6. Mutation watching — A MutationObserver watches the target container. If headings are added or removed after load, the TOC automatically rebuilds itself after a short debounce.

Quick Start

Basic — auto-detect headings
<OmeTableOfContents
structure='{{"tag":"nav"}}'
content='{{"showLabel":true,"label":"On this page"}}'
settings='{{"depth":"3","offset":"0"}}'
targeting='{{"targetSelector":""}}'
behavior='{{"treeDisplay":"expanded"}}'
mobile='{{"enabled":false}}'
/>

This produces a <nav> that scans the page for H2–H3 headings, shows an "On this page" label, and highlights the active heading on scroll.

Blog post with deep headings, scroll offset, and mobile accordion
<OmeTableOfContents
structure='{{"tag":"nav"}}'
content='{{"showLabel":true,"label":"Chapters"}}'
settings='{{"depth":"4","offset":"96"}}'
targeting='{{"targetSelector":"article.post-content"}}'
behavior='{{"treeDisplay":"active-branch"}}'
mobile='{{"enabled":true,"breakpoint":"768","initialState":"collapsed"}}'
styling='{{"class":["my-toc"],"labelClass":["my-toc__label"],"branchClass":["my-toc__list"],"itemClass":["my-toc__item"]}}'
mobile_styling='{{"mobileButtonClass":["my-toc__mobile-btn"],"mobilePanelClass":["my-toc__mobile-panel"],"mobileChevronClass":["my-toc__mobile-chevron"]}}'
/>

Props

Structure

Structure Props

PropTypeDefaultDescription
tagstring"nav"

HTML tag used for the root element. Change to "div" if you need a generic container, but "nav" is recommended for accessibility since the TOC is a navigation landmark.

Content

Content Props

PropTypeDefaultDescription
showLabelbooleantrue

Shows a label above the generated table of contents. When false, no label text is rendered and the list starts immediately.

labelstring"On this page"

Text shown as the table of contents label. When mobile accordion is enabled, this same text is used as the disclosure button label.

Settings

Settings Props

PropTypeDefaultDescription
depth"2" | "3" | "4" | "5" | "6""3"

Includes H2 through the selected heading level. H1 is always skipped. "3" captures H2 and H3 — the most common range for blog posts and documentation. Use "2" for a flat H2-only list, or "4""6" for deeply structured content.

offsetstring"0"

Pixel offset applied to target headings through scroll-margin-top. Set this to match your sticky header height so headings land below the header when scrolled to.

Targeting

Targeting Props

PropTypeDefaultDescription
targetSelectorstring""

CSS selector for the content container to scan for headings. When empty, the runtime falls back to main, article, [role='main'], .entry-content, .wp-block-post-content, then body — whichever it finds first that is not inside the TOC root itself. Set this when your content lives in a specific container and you want to exclude headings from sidebars, headers, or footers.

Behavior

Behavior Props

PropTypeDefaultDescription
treeDisplay"expanded" | "active-branch""expanded"

Controls nested branch visibility. "expanded" shows all heading levels at all times. "active-branch" reveals nested links only under the currently active H2 branch — all other sub-lists are hidden. Use "active-branch" for long pages with many headings to reduce visual noise.

Mobile

Mobile Props

PropTypeDefaultDescription
enabledbooleanfalse

Turns the label into a disclosure button below the configured breakpoint. The TOC list collapses into an accordion panel that users can expand and collapse.

breakpointstring"768"

Viewport width in pixels where mobile accordion mode starts. Below this width, the label is replaced with a toggle button and the panel collapses.

initialState"collapsed" | "expanded""collapsed"

Initial mobile accordion state when the page loads below the breakpoint. "collapsed" hides the list until the user taps the button. "expanded" shows it immediately.

Styling

Styling Props

PropTypeDefaultDescription
classclassome-toc-root-default

CSS class applied to the TOC root element.

labelClassclassome-toc-label-default

CSS class applied to the label element.

branchClassclassome-toc-branch-default

CSS class applied to all generated <ul> branch lists.

itemClassclassome-toc-item-default

CSS class applied to all generated <li> items.

Mobile Styling

Mobile Styling Props

PropTypeDefaultDescription
mobileButtonClassclassome-toc-mobile-button-default

CSS class applied to the mobile disclosure button (visible only when mobile accordion is enabled and viewport is below the breakpoint).

mobilePanelClassclassome-toc-mobile-panel-default

CSS class applied to the mobile disclosure panel that wraps the TOC list in mobile mode.

mobileChevronClassclassome-toc-mobile-chevron-default

CSS class applied to the chevron SVG icon inside the mobile disclosure button. The chevron rotates 180 degrees when expanded.

Advanced Styles

Advanced Styles Props

PropTypeDefaultDescription
enabledbooleanfalse

Reveals H2–H6 level-specific class controls up to the configured depth. When enabled, you can style each heading level's branches and items independently.

When Advanced Styles is enabled, the following per-level props become available for each heading level up to the configured depth:

LevelBranch CSS ClassItem CSS Class
H2ome-toc-h2-branch-defaultome-toc-h2-item-default
H3ome-toc-h3-branch-defaultome-toc-h3-item-default
H4ome-toc-h4-branch-defaultome-toc-h4-item-default
H5ome-toc-h5-branch-defaultome-toc-h5-item-default
H6ome-toc-h6-branch-defaultome-toc-h6-item-default

Each level-specific class sets a --ome-toc-level custom property on the element, useful for writing level-aware CSS.


Active State Attributes

The runtime uses data attributes (not classes) to mark active state. Target these in your CSS:

AttributeApplied toWhen
data-ome-active<li data-ome-toc-item>The currently active heading's list item.
data-ome-active-branch<li data-ome-toc-item>The root H2 branch item containing the active heading (only one at a time).
aria-current="location"<a data-ome-toc-link>The link pointing to the currently active heading.

CSS Example

/* Style the active link */
[data-ome-toc-item][data-ome-active] > a {
color: var(--primary, #2563eb);
font-weight: 700;
}

/* Dim inactive branches in active-branch mode */
[data-ome-toc-item]:not([data-ome-active-branch]) {
opacity: 0.5;
}

Mobile Accordion

When mobile.enabled is true, the component transforms below the breakpoint:

  • The static label is replaced with a <button> that toggles the TOC list.
  • The button includes a chevron icon that rotates when expanded.
  • The panel is hidden/shown via the hidden attribute.
  • ARIA attributes (aria-expanded, aria-controls) are managed automatically.
  • When the viewport crosses back above the breakpoint, the button is removed and the static label is restored.

Heading Discovery Details

Slug Generation

Headings without an existing id attribute receive an auto-generated slug:

  1. Text is trimmed, lowercased, and Unicode-normalized (NFKD).
  2. Diacritics are stripped.
  3. Quotes and apostrophes are removed.
  4. Non-alphanumeric characters are replaced with hyphens.
  5. Leading/trailing hyphens are removed.
  6. If the result is empty, the fallback slug "section" is used.

Duplicate IDs are resolved by appending a numeric suffix: heading, heading-2, heading-3, etc. Existing IDs on headings are always preserved.

Content Container Fallbacks

When targetSelector is empty or does not match a valid element outside the TOC root, the runtime tries these selectors in order:

  1. main
  2. article
  3. [role='main']
  4. .entry-content
  5. .wp-block-post-content
  6. body

The first element found that is not inside the TOC root is used as the heading source.


Common Mistakes

Placing the TOC inside the content container it scans

If the TOC root is inside the element it targets for headings, the runtime skips it correctly — but the targetSelector must resolve to an element outside the TOC root. If your selector accidentally targets the TOC itself, no headings will be found and the component hides itself. Use a specific content selector (like article.post-content) when the TOC is nested inside a shared layout.

Expecting active classes instead of data attributes

Active state is communicated through data-ome-active, data-ome-active-branch, and aria-current="location" — not CSS classes. Do not write selectors like .is-active or .active-link. Use [data-ome-active] > a in your CSS instead.

Setting depth to include H1

H1 is always excluded. The minimum heading level is H2. Setting depth to "2" produces a flat list of H2 headings only — no nesting.

Forgetting the scroll offset with a sticky header

If your site has a sticky header, headings will scroll behind it when the user clicks a TOC link. Set offset to your header height (e.g. "96") so scroll-margin-top pushes the heading below the header.


FAQs

What happens when there are no headings?

The TOC root is hidden (hidden attribute) and no links are rendered. It becomes completely invisible — no empty container or label is shown. If headings are later added via JavaScript (e.g. dynamic content), the MutationObserver triggers a rebuild and the TOC reappears automatically.

Can I have multiple TOC components on the same page?

Yes. Each TOC instance manages its own heading collection, active tracking, and mobile accordion independently. Use targetSelector on each instance to point to different content containers so they don't scan each other's headings.

How does the component handle duplicate heading text?

Headings with identical text receive unique auto-generated IDs: the first gets some-heading, the second some-heading-2, and so on. Headings that already have an id attribute keep that ID unchanged — even if it duplicates another heading's slug.

Does the TOC update when content changes dynamically?

Yes. A MutationObserver watches the target container's subtree. When headings are added or removed, the TOC rebuilds itself after a 50ms debounce. The IntersectionObserver for active tracking is also re-created during the rebuild.

How do I style each heading level differently?

Enable Advanced Styles (advanced_styles.enabled = true), then set per-level branch and item classes. Each level's default class sets --ome-toc-level on the element. You can use this custom property to write level-aware CSS:

[data-ome-toc-item] {
padding-inline-start: calc((var(--ome-toc-level, 2) - 2) * 1rem);
}
Can I use the TOC without the label?

Yes. Set content.showLabel to false. The list renders without any label text. If mobile accordion is enabled, the disclosure button uses the aria-label on the root nav element instead.