Table of contents for Kirby 3

Thank you both! I’m “extending” the field method that @texnixe suggested to support different levels and I think it might work.

Maybe a bit late, but I just converted my old Kirby TOC plugin to an independent class. It will work as a starting point for a Kirby plugin, but can also work without Kirby.


It will work with multiple heading levels.

$toc = new TOC();
$text = $toc->anchorHeadings($text);

echo $toc->list($text); ?>
echo $text;

Thanks! I managed to solve it following @texnixe suggestion. But for sure your solution is going to be useful for others.

How do I implement this into a usable KirbyTag?
I can’t seem to work it out.

EDIT: Have figured it out.

Hi Guido Ferreyra,

Would you mind sharing your implementation? Can’t figure out how to make it work.
Would be much appreciated!

EDIT: Have figured it out.

For anyone interested I’ve updated my repo with changed syntax, much simpler, improved performance and added docs.

$toc = new PHPTableOfContents($html);

// Output the table of contents
echo $toc->list();

// Output the content
echo $toc->html();

@texnixe Can you point me at some doc/examples for implementing this fieldMethods plugin please?

I’m feeling my way into PHP and I think I’ve run into the practical limit of my (limited) abilities … I’ve read the plugin docs, but they seem to be more about creating plugins and less about how to use them in your code.

@mikehargreaves Could you be more specific about what information exactly is missing and what you need?

If you follow the link to the repo, you can see how the method is implemented, you would have to extract this, put it into a plugin file, so that you can then use it in your templates.

The snippet gets called here with the method:

And the snippet itself:

Let me know if you need more information.

@texnixe The piece I was missing was the (very basic) how to call the plugin methods – is that the right term? – I’d pieced parts of it together looking at the plugin code, but never having written any code that calls a plugin, I wasn’t really sure how to go about it. The examples you provided are perfect, thank you.

Hello all! I was in need of the same, so I did the following (which is not workign at the moment).

Created the toc.php snippet file:

<?php if($item->count() > 3): ?>
  <nav class="toc | -mb:large">

    <h2 class="h4">Table of Contents</h2>

      <?php foreach($item as $headline): ?>
        <li><a href="<?= $headline->url() ?>"><span><?= $headline->text() ?></span></a></li>
      <?php endforeach ?>

<?php endif ?>

I called the snipper in my template:

<?php snippet('toc', $page->text()->headlines()) ?>

Then I created the plugin toc/index.php extracting the code from the repo:


Kirby::plugin('theme/toc', [

  'headlines' => function($field, $headline = 'h2') {

    preg_match_all('!<' . $headline . '.*?>(.*?)</' . $headline . '>!s', $field->kt()->value(), $matches);

    $headlines = new Collection;

    foreach ($matches[1] as $text) {
      $headline = new Obj([
        'id'   => $id = '#' . Str::slug(Str::unhtml($text)),
        'url'  => $id,
        'text' => trim(strip_tags($text)),

      $headlines->append($headline->url(), $headline);


    return $headlines;



I’m assuming this last part I’m not doing it right.

Were you able to solve it?

You have to pass an array of key/value pairs as second argument!

<?php snippet('toc', ['item' => $page->text()->headlines()]) ?>

(I’d use items or headlines here instead of the singular item, because I always find it confusing to loop through a single item, but that’s just personal preference)

Hi! Thanks for replying. I’ve debug mode configured, and I’m getting the following error:

Whoops \ Exception \ ErrorException (E_NOTICE)
Object of class Kirby\Cms\Field could not be converted to int

That’s apparently coming from the same snippet file I copyed above.

Do you have any clue what could it be?

There’s also another issue, because your plugin is missing the name of the extension you want to create.

Have a look at the docs for field methods:

Which reminds me that writing a TOC recipe is next on my list.

Thanks! I’ll take a look and try to solve it. It would also be great to have that recipe to follow a proper way to do it, since I’m just learning and examples help a lot. Thanks again!

Some time ago I ported the wonderful Kirby 2 code from @jenstornell (PHP Table of Contents: to Kirby 3.

In the following I show the main parts. Because of the CSS code and the license file, I refer to the Kirby 2 code in the above link to @jenstornell’s code on GitHub.


Create a new directory “site\plugins\jenstornell_toc_k3\” with the file “site\plugins\jenstornell_toc_k3\index.php” like:

<?php // site\plugins\jenstornell_toc_k3\index.php
      // based on, version 1.2
      // adapted for K3 by HeinerEF - 07.-11.01.2020

@include_once __DIR__ . '/src/table-of-contents.php';

Kirby::plugin('jenstornell/php-table-of-contents', [
  'hooks' => [
    'kirbytext:after' => function ($text) {
      $mypos = (stripos($text, '(toc: )') === FALSE);
      if (!($mypos)) {
        $toc = new PHPTableOfContents($text);
        $text = preg_replace('#<p>\(toc: \)</p>#i', "\n" . '      <div class="toc">'
              . "\n" . '        <p>Table of contents of the page:</p>'
              . $toc->list() . '      </div>' . "\n", $toc->html());
      return $text;

Create a new directory “site\plugins\jenstornell_toc_k3\src\” with the file “site\plugins\jenstornell_toc_k3\src\table-of-contents.php” like:

<?php // site\plugins\jenstornell_toc_k3\src\table-of-contents.php
      // based on, version 1.2
      // some changes by HeinerEF for K3 - 07.-10.01.2020 (proper tag hierarchy from h2 to h6)
      // use "Str::slug(  )" from K3 by HeinerEF

class PHPTableOfContents {
  private $headings;
  private $html;

  public function __construct($html) {
    $this->html = $html;

  // Html
  public function html() {
    $html = $this->html;
    $matches = $this->headings;
    foreach($matches[1] as $index => $item) {
      $html = str_replace(
        '>' . $item . '</h',
        '><a id="' . $matches[2][$index] . '"></a>' . $item . ' </h', // 25.04.2020 by HeinerEF
    return $html;

  // Generate table of content nested list
  // 07.-10.01.2020 changes by HeinerEF (proper tag hierarchy from h2 to h6)
  public function list() {
    $out = '';
    $old_depth = 0;
    $matches = $this->headings;
    foreach($matches[1] as $key => $item) {
      $depth = substr($matches[0][$key], 2, 1) - 2;
      if($old_depth > $depth) {
        while ($old_depth > $depth) {
          $out .= "\n          </ol><!-- #".($old_depth+2)." -->";
      } elseif($old_depth < $depth) {
        $out .= "\n          <li>\n";
        $out .=   "          <ol><!-- #".($old_depth+1)." -->";
      $out .= sprintf("
            <a href='#%s'>%s</a>
          </li>", $matches[2][$key], $item);
    while ($old_depth > 0) {
      $out .= "\n          </ol><!-- #".($old_depth+2)." -->";
    return "\n        <ol><!-- #1 -->" . $out . "\n        </ol><!-- #1 -->\n";

  // Set headings
  private function setHeadings() {
    preg_match_all('|<h[^>]+>(.*)</h[^>]+>|iU', $this->html, $matches);
    $slugs = [];
    foreach($matches[1] as $item) {
      $slugs[] = Str::slug($item);  // changed by HeinerEF using K3
    $this->headings = $matches;
    $this->headings[2] = $slugs;

How to use

In the Panel field add three extra lines

(toc: )

where you want to have the TOC.


Of course, with this technique it is necessary for all headings to be different in the text for the links to work.

Good luck!

@HeinerEF Thanks for your reply! I’m coming back to this after a long while. I understand that, in how to use:

(toc: )

That means as part of the text content that you can type in the field, right? Have you also used it as part of a template? For example for a post page.

No, I always use it in textareas of the Panel. For my websites no template should always have a ToC.

But at the Kirby team prepares a recipe for this. May be you can wait until it is public…

1 Like

Oh great, yes, for sure I can wait. Thanks for the tip!

Now published:



I like this recipe very much :smile:! Thank you!

Do you want to add a hint, that all heading texts have to be unique? At the moment I cannot test your code, but it looks like this is a requirement…

May be a check for the unambiguity of these texts is great…, may be in a next part…