1.How Content TOC works
Content TOC is built around a single idea: parse the field once, then read pieces off it. The :process tag takes a block of HTML, walks its headings, slugs and de-duplicates anchor ids, and caches the parsed result for the rest of the template. Every other tag — the TOC list, the anchored body, reading time, the JSON-LD — reads from that one parse.
Everything renders server-side. The table of contents and the heading anchors are in the HTML that leaves EE, so they're crawlable, work without JavaScript, and survive in full-page caches. The only feature that ships a script is the opt-in scroll-spy (§7).
Why "process first"
Because the TOC and the body are usually rendered in different places — a sidebar and a main column — but must agree on the same anchors. Parsing once and reading both off the result guarantees the link in the TOC always matches the id in the body, even after de-duplication.
2.Install & quick start
- Buy + download from expressionengine.com/add-ons/content-toc.
- Extract to
system/user/addons/content_toc/. - Install via CP → Add-ons → Content TOC → Install.
- Set defaults on the Settings page (heading levels, nested vs flat, scroll-spy, reading-time speed) — or leave them and override per call.
- Wire your template with the snippet below.
{!-- parse once at the top --} {exp:content_toc:process}{your_body_field}{/exp:content_toc:process} {exp:content_toc:reading_time} {exp:content_toc:has_toc}<h4>Contents</h4>{exp:content_toc:toc}{/exp:content_toc:has_toc} <article>{exp:content_toc:body}</article>
:has_toc so the "Contents" label only appears when there are enough headings to build a list (see min_count in §10).3.The process tag
{exp:content_toc:process}…{/exp:content_toc:process} is a tag pair. Put your field's content between the tags. It returns nothing visible — its job is to parse and cache. Call it once, before any other Content TOC tag in the template.
What it does to your headings
- Collects headings within the configured level range (default
h2–h4). - Generates a URL-safe
idfrom each heading's text (e.g. "Boss fight: the Twins" →boss-fight-the-twins). - De-duplicates collisions with a numeric suffix (
-2,-3). - Leaves any heading that already has an
iduntouched and uses that id in the TOC.
4.Rendering the TOC
{exp:content_toc:toc} emits a linked list of the headings collected by :process. Flat by default; nested by level when nested="yes".
{exp:content_toc:toc nested="yes" numbered="no" list_id="toc-list" }
| Parameter | What |
|---|---|
nested | yes nests h3/h4 under their parent h2; no renders a flat list. |
numbered | yes auto-numbers entries. |
list_id | id for the generated <ul> — needed if you target it from your own CSS or JS. |
{exp:content_toc:has_toc}…{/exp:content_toc:has_toc} is a conditional pair that only renders its contents when a TOC was actually built (enough headings — see min_count). {exp:content_toc:count} returns the heading count if you want to branch on it yourself.
5.The anchored body
{exp:content_toc:body} emits the field's HTML with the heading anchors injected — this is what you render in place of the raw field. The ids match the links produced by :toc.
<article>{exp:content_toc:body}</article>
:body returns the same HTML you passed to :process (plus anchors), you can keep any other field formatting you were already applying — Content TOC only touches the heading tags.6.Reading time
{exp:content_toc:reading_time} returns an estimate from the word count of the processed content.
{exp:content_toc:reading_time wpm="220" format="{n} min read"}
| Parameter | What |
|---|---|
wpm | Words per minute used for the estimate (per-site default in Settings). |
format | Output template; {n} is replaced with the rounded minute count. |
:reading_time reads from the parsed content, so it must come after :process in the template. If it renders empty, check the order.7.Scroll-spy
With scroll-spy on, the TOC entry for the section currently in view gets an active class, and — when the TOC lives in its own scroll box — that active entry is scrolled into view so it never drifts off-screen on a long page.
Enable it as a per-site default in Settings, or per call with scrollspy="yes" on the :toc tag. The add-on emits a small self-contained script; style the active state yourself:
/* your stylesheet */ .toc-list a.active { color: #1abfb0; font-weight: 600; }
list_id you give the TOC and the heading anchors in the body. If you render your own TOC markup, keep list_id consistent so the script can find the list.8.Back-to-top
Optional back-to-top links after each section. Turn it on in Settings or with back_to_top="yes", and point it at the container the links should scroll to:
{exp:content_toc:body back_to_top="yes" back_to_top_target="article-top"}
| Parameter | What |
|---|---|
back_to_top | yes appends a back-to-top link after each section. |
back_to_top_target | The id of the element to scroll back to — usually the article wrapper or the TOC. |
back_to_top_target must be an element that actually exists on the page, and it must not be the same id you gave the TOC list. Pointing both at the same id is the most common reason back-to-top "doesn't work".9.schema.org ItemList
{exp:content_toc:jsonld} emits a JSON-LD ItemList describing the chapter list — structured data that tells search engines what sections the document contains.
{exp:content_toc:jsonld base_url="{title_permalink='walkthrough/index'}" name="{title} — walkthrough" }
| Parameter | What |
|---|---|
base_url | The page URL each list item's anchor is appended to (so items get absolute #anchor URLs). |
name | Name of the ItemList (typically the document title). |
:jsonld tag inside your <head> (or wherever you collect structured data). It's independent of where the visible TOC is rendered.10.Settings & parameters
Every behaviour has a per-site default on the Settings page, and each is overridable per call with a tag parameter of the same name. Parameters win over settings; settings win over the built-in fallback.
| Setting / param | Default | What it controls |
|---|---|---|
levels (min/max heading) | h2–h4 | Which heading levels are collected and anchored. |
nested | yes | Nested vs flat TOC. |
numbered | no | Auto-number TOC entries. |
scrollspy | no | Highlight + auto-scroll the active section. |
min_count | 2 | Minimum headings before a TOC (and :has_toc) renders. |
back_to_top | no | Back-to-top links after each section. |
wpm | 200 | Words per minute for reading-time. |
11.Multi-Site Manager
Settings are stored per site. Switch the EE site picker first; the Settings page edits the current site's defaults only. The template tags resolve the current site's settings automatically at render time, so the same template behaves correctly across all MSM sites.
12.Common questions
Does it require JavaScript?
No — the TOC, anchors, reading time and JSON-LD are all server-rendered. Only scroll-spy adds a small script, and it's opt-in.
Will it change my content in the database?
No. Anchors are injected into the rendered output only. The stored field is never modified.
Can I render the TOC and the body in different places?
Yes — that's the point of the process-first design. Call :process once at the top, then put :toc in a sidebar and :body in your main column. They share the same anchors.
The reading time / TOC is empty — why?
The most common cause is order: every tag reads from :process, so :process must come first in the template. For an empty TOC specifically, also check that the content has at least min_count headings within the configured level range.
Does it work on any field, or only article bodies?
Any HTML field — body fields, Grid/Fluid cell HTML, a long custom field. It started on game-walkthrough bodies but has no assumptions about the channel or field.
How do I uninstall?
Uninstall via Add-ons → Content TOC → Uninstall; the settings table is dropped cleanly. Remove the tags from your templates first so they don't render as literal text.