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
- 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. - Target discovery — The runtime reads
targetSelectorto find the content container. If empty or invalid, it falls back tomain,article,[role='main'],.entry-content,.wp-block-post-content, thenbody. The TOC root itself is always excluded from heading scanning. - 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). - Tree building — Headings are organized into a nested
<ul>/<li>tree that mirrors the heading hierarchy. - Active tracking — An
IntersectionObserverwatches all collected headings. As the user scrolls, the first visible heading's corresponding link receivesaria-current="location", and the item getsdata-ome-active. The root H2 branch item receivesdata-ome-active-branch. - Mutation watching — A
MutationObserverwatches the target container. If headings are added or removed after load, the TOC automatically rebuilds itself after a short debounce.
Quick Start
<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.
<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
| Prop | Type | Default | Description |
|---|---|---|---|
tag | string | "nav" | HTML tag used for the root element. Change to |
Content
Content Props
| Prop | Type | Default | Description |
|---|---|---|---|
showLabel | boolean | true | Shows a label above the generated table of contents. When |
label | string | "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
| Prop | Type | Default | Description |
|---|---|---|---|
depth | "2" | "3" | "4" | "5" | "6" | "3" | Includes H2 through the selected heading level. H1 is always skipped. |
offset | string | "0" | Pixel offset applied to target headings through |
Targeting
Targeting Props
| Prop | Type | Default | Description |
|---|---|---|---|
targetSelector | string | "" | CSS selector for the content container to scan for headings. When empty, the runtime falls back to |
Behavior
Behavior Props
| Prop | Type | Default | Description |
|---|---|---|---|
treeDisplay | "expanded" | "active-branch" | "expanded" | Controls nested branch visibility. |
Mobile
Mobile Props
| Prop | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | 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. |
breakpoint | string | "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. |
Styling
Styling Props
| Prop | Type | Default | Description |
|---|---|---|---|
class | class | ome-toc-root-default | CSS class applied to the TOC root element. |
labelClass | class | ome-toc-label-default | CSS class applied to the label element. |
branchClass | class | ome-toc-branch-default | CSS class applied to all generated |
itemClass | class | ome-toc-item-default | CSS class applied to all generated |
Mobile Styling
Mobile Styling Props
| Prop | Type | Default | Description |
|---|---|---|---|
mobileButtonClass | class | ome-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). |
mobilePanelClass | class | ome-toc-mobile-panel-default | CSS class applied to the mobile disclosure panel that wraps the TOC list in mobile mode. |
mobileChevronClass | class | ome-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
| Prop | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | 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:
| Level | Branch CSS Class | Item CSS Class |
|---|---|---|
| H2 | ome-toc-h2-branch-default | ome-toc-h2-item-default |
| H3 | ome-toc-h3-branch-default | ome-toc-h3-item-default |
| H4 | ome-toc-h4-branch-default | ome-toc-h4-item-default |
| H5 | ome-toc-h5-branch-default | ome-toc-h5-item-default |
| H6 | ome-toc-h6-branch-default | ome-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:
| Attribute | Applied to | When |
|---|---|---|
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
hiddenattribute. - 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:
- Text is trimmed, lowercased, and Unicode-normalized (NFKD).
- Diacritics are stripped.
- Quotes and apostrophes are removed.
- Non-alphanumeric characters are replaced with hyphens.
- Leading/trailing hyphens are removed.
- 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:
mainarticle[role='main'].entry-content.wp-block-post-contentbody
The first element found that is not inside the TOC root is used as the heading source.
Common Mistakes
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.
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.
H1 is always excluded. The minimum heading level is H2. Setting depth to "2" produces a flat list of H2 headings only — no nesting.
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.