Documentation · Content TOC for ExpressionEngine

Server-side tables of contents, the way EE templates want them.

Content TOC parses an HTML field once, injects stable heading anchors, and gives you independent tags for the table of contents, the anchored body, reading time, and schema.org markup — with a per-site default for every behaviour and a same-named tag parameter to override it.

Add-on version: v2.1.4 ExpressionEngine 7+ Commercial · single-site license

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

  1. Buy + download from expressionengine.com/add-ons/content-toc.
  2. Extract to system/user/addons/content_toc/.
  3. Install via CP → Add-ons → Content TOC → Install.
  4. Set defaults on the Settings page (heading levels, nested vs flat, scroll-spy, reading-time speed) — or leave them and override per call.
  5. 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>
TipWrap the TOC heading in :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 h2h4).
  • Generates a URL-safe id from 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 id untouched and uses that id in the TOC.
NoteThe original field is never mutated in the database — anchors are injected into the rendered output only.

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"
}
ParameterWhat
nestedyes nests h3/h4 under their parent h2; no renders a flat list.
numberedyes auto-numbers entries.
list_idid 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>
NoteBecause :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"}
ParameterWhat
wpmWords per minute used for the estimate (per-site default in Settings).
formatOutput template; {n} is replaced with the rounded minute count.
Heads up: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; }
NoteScroll-spy keys off the 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"}
ParameterWhat
back_to_topyes appends a back-to-top link after each section.
back_to_top_targetThe id of the element to scroll back to — usually the article wrapper or the TOC.
Heads upback_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"
}
ParameterWhat
base_urlThe page URL each list item's anchor is appended to (so items get absolute #anchor URLs).
nameName of the ItemList (typically the document title).
TipPlace the :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 / paramDefaultWhat it controls
levels (min/max heading)h2–h4Which heading levels are collected and anchored.
nestedyesNested vs flat TOC.
numberednoAuto-number TOC entries.
scrollspynoHighlight + auto-scroll the active section.
min_count2Minimum headings before a TOC (and :has_toc) renders.
back_to_topnoBack-to-top links after each section.
wpm200Words 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.