Search by

mod-lab / craft-heading-anchors

mod-lab

Automatically adds anchor ids to headings in rendered HTML and lists them for in-page navigation in Craft CMS

Package info

github.com/Minds-On-Design-Lab/heading-anchors

Type:craft-plugin

pkg:composer/mod-lab/craft-heading-anchors

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-10-29 19:16 UTC

This package is auto-updated.

Last update: 2026-10-29 19:26:16 UTC


README

Automatically gives headings in rendered HTML unique, readable anchor ids (#our-mission), and lists them so templates can build an "On this page" menu. Built for rich content such as a CKEditor field with nested entries, where headings come from both the editor's text and from content block templates.

The plugin only handles ids and the heading list. The menu's markup, styling and any scroll behaviour stay in your templates.

Requirements

This plugin requires Craft CMS 5.0.0 or later, and PHP 8.2 or later.

Installation

composer require mod-lab/craft-heading-anchors
php craft plugin/install heading-anchors

Usage

Add anchor ids

Wrap the rendered content in the headingAnchors filter:

{% apply headingAnchors %}
  {% for chunk in entry.mainContent %}
    …
  {% endfor %}
{% endapply %}

Every <h2> with text gets an id and a data-heading-anchor attribute:

<h2 id="our-mission" data-heading-anchor>Our Mission</h2>
  • Ids are the kebab-cased heading text (the same as Twig's |kebab), with & read as "and". Repeats get -2, -3, ….
  • No collisions: ids never clash with any id already in the HTML.
  • Existing ids are kept: a heading that already has an id keeps it and is only marked.
  • Empty headings are skipped.

List the headings

headingAnchorList(html) returns the anchored headings in document order, with plain text:

{% set content %}{% include '_components/contentBlocks' %}{% endset %}
{% set headings = headingAnchorList(content) %}

{% if headings|length >= 2 %}
  <nav aria-label="On this page">
    <ol>
      {% for heading in headings %}
        <li><a href="#{{ heading.id }}">{{ heading.text }}</a></li>
      {% endfor %}
    </ol>
  </nav>
{% endif %}

{{ content }}

Render the content into a variable first, then build the menu from it, so the menu can appear before the content on the page.

Exclude something

Add data-heading-anchors-ignore to any element to skip it and everything inside it, e.g. a third-party embed:

<div data-heading-anchors-ignore>{{ entry.embedCode|raw }}</div>

Configuration

Optionally, create config/heading-anchors.php:

<?php

return [
    // Heading tags to anchor and list
    'tags' => ['h2'],
    // Attribute that excludes an element and its contents
    'ignoreAttribute' => 'data-heading-anchors-ignore',
];

How it works

The HTML is scanned, not rebuilt through a DOM. Only the heading's opening tag is changed, by inserting id="…" data-heading-anchor after the tag name, so everything else comes back byte-for-byte: Alpine attributes (@click, :class), SVG, whitespace and embed code.

Comments and the contents of <script>, <style>, <textarea> and <title> are never treated as markup. A pasted embed whose script contains "<h2>…</h2>" is left untouched.

Headings that JavaScript adds after the page loads, or that live inside an <iframe>, aren't part of the rendered HTML, so they aren't anchored or listed.

An excluded element is skipped up to its own closing tag, even if markup inside it is sloppy (e.g. an unclosed <p>). If the excluded element itself is never closed, its end can't be found, so its headings are anchored as normal.

Development

composer install
composer test       # PHPUnit; runs without a Craft install
composer phpstan
composer check-cs

The tests exercise the Anchors service directly against tests/fixtures/content.html. To try the Twig filter and function in a real site, install the plugin from a local Composer path repository.