@svelte-put/toc GitHub

action and utilities for building table of contents

@svelte-put/toc @svelte-put/toc @svelte-put/toc @svelte-put/toc changelog

Acknowledgement

This package relies on Svelte action and attempts to stay minimal. If you are looking for a declarative, component-oriented solution, checkout janosh/svelte-toc.

Installation

npm install --save-dev @svelte-put/toc
pnpm add -D @svelte-put/toc
yarn add -D @svelte-put/toc

Migration Guide

In version 5, the items property of TocStore and TocInitEventDetail is now a Map instead of plain object as in version 4. This enables better performance and properly preserves the order of collected toc elements.

<section>
  <h2>Table of Contents</h2>
  {#if Object.values($tocStore.items).length}
  {#if $tocStore.items.size}
    <ul>
      {#each Object.values($tocStore.items) as tocItem}
      {#each $tocStore.items.values() as tocItem}
        <li>
          ...
        </li>
      {/each}
    </ul>
  {/if}
</section>

Introduction

@svelte-put/toc operates at runtime and does the following:

  • search for matching elements (default: :where(h1, h2, h3, h4, h5, h6)),

  • generate id attribute from element textContent,

  • add anchor tag to element,

  • attach IntersectionObserver to each matching element to track its visibility on the screen,

  • expose necessary pieces for building table of contents.

It is recommended to use the complementary @svelte-put/preprocess-auto-slug package for handling 2 and 3 at build time. toc will skip those operations if they are already handled by preprocess-auto-slug.

Notice toc relies on IntersectionObserver and not on:scroll for better performance and predictability. See this article for a performance comparison between the two.

The table of contents in this documentation site is generated by toc itself. Check out its source code here (search for tocStore).

Quick Start

Given the following Svelte source code, let’s see how toc does its job.

<script>
  import { toc, createTocStore, toclink } from '@svelte-put/toc';
  const tocStore = createTocStore();
</script>

<main use:toc={{ store: tocStore, observe: true }}>
  <h1>Page Heading</h1>

  <section>
    <h2>Table of Contents</h2>
    {#if $tocStore.items.size}
      <ul>
        {#each $tocStore.items.values() as tocItem}
          <li>
            <!-- svelte-ignore a11y-missing-attribute -->
            <!-- eslint-disable-next-line svelte/valid-compile -->
            <a use:toclink={{ store: tocStore, tocItem, observe: true }} />
          </li>
        {/each}
      </ul>
    {/if}
  </section>

  <section>
    <h2>Section Heading Level 2</h2>
    <p>...</p>
  </section>

  <section>
    <h3>Section Heading Level 3</h3>
    <p>...</p>
  </section>
  <!-- ... -->
</main>

Notice the highlighted lines, specifically:

  • the createTocStore helper is used to create an idiomatic Svelte store, whose items property will be populated with the extracted toc elements and can track activeItem if the observe option is set to true
  • the complementary optional toclink action is used on anchor tags within the table of contents to help save some manual effort and keep behavior consistent with the main toc action. See Complementary TocLink for more details.
<main
  data-toc-observe-for="page-heading"
  data-toc-root="ee4f13a3-dfec-401d-b52c-a52550e20ddf"
  data-toc-observe-active-id="section-heading-level-3"
>
  <h1 id="page-heading" data-toc="">
    <a aria-hidden="true" tabindex="-1" href="#page-heading" data-toc-anchor="">#</a>Page Heading
  </h1>
  <section data-toc-observe-for="table-of-contents">
    <h2 id="table-of-contents" data-toc="">
      <a aria-hidden="true" tabindex="-1" href="#table-of-contents" data-toc-anchor="">#</a>Table of
      Contents
    </h2>
    <ul>
      <li>
        <a href="#page-heading" data-toc-link-for="page-heading" data-toc-link-current="false"
          >Page Heading</a
        >
      </li>
      <li>
        <a
          href="#table-of-contents"
          data-toc-link-for="table-of-contents"
          data-toc-link-current="false">Table of Contents</a
        >
      </li>
      <li>
        <a
          href="#section-heading-level-2"
          data-toc-link-for="section-heading-level-2"
          data-toc-link-current="false">Section Heading Level 2</a
        >
      </li>
      <li>
        <a
          href="#section-heading-level-3"
          data-toc-link-for="section-heading-level-3"
          data-toc-link-current="true">Section Heading Level 3</a
        >
      </li>
    </ul>
  </section>
  <section data-toc-observe-for="section-heading-level-2">
    <h2 id="section-heading-level-2" data-toc="">
      <a aria-hidden="true" tabindex="-1" href="#section-heading-level-2" data-toc-anchor="">#</a
      >Section Heading Level 2
    </h2>
    <p>...</p>
  </section>
  <section data-toc-observe-for="section-heading-level-3">
    <h3 id="section-heading-level-3" data-toc="">
      <a aria-hidden="true" tabindex="-1" href="#section-heading-level-3" data-toc-anchor="">#</a
      >Section Heading Level 3
    </h3>
    <p>...</p>
  </section>
</main>

Toc Action

use:toc will search for matching elements only from descendants of the element it is attached to. In Quick Start, that’s the <main> element. To search from everything on the page, use it on <svelte:body>.

<svelte:body use:toc>

No Dynamic Update

During development, you may notice that toc does not update when you change the action parameters at runtime and require a page refresh to work again. This is because currently toc only runs once on mount.

Supporting dynamic update is quite a task (tracking what’s changed and avoiding duplicate operations) that will increase the bundle size & complexity but is not practically useful in most use cases (how often does a table of contents change at runtime?).

If you think otherwise and have a valid use case, please raise an issue.

use:toc accepts an optional config object with the TocConfig interface. It is recommended to make use of your code editor and language server for API discovery. But, should it be necessary, you can refer to the full type definition here.

Options to toc are global and affect all matching elements. Some of them may be overridden per matching element, see Toc Data Attributes.

CustomEvents

In Quick Start, a Svelte store created with the createTocStore helper is used to keep code minimal. Alternatively, you may listen for tocinit and tocchange CustomEvents.

<script lang="ts">
  import { toc } from '@svelte-put/toc';
  import type { TocInitEventDetail, TocChangeEventDetail } from '@svelte-put/toc';
  function handleTocInit(event: CustomEvent<TocInitEventDetail>) {
    const { items } = event.detail;
    console.log('Extracted item', items);
  }
  function handleTocChange(event: CustomEvent<TocChangeEventDetail>) {
    const { activeItem } = event.detail;
    console.log('Item currently on viewport', activeItem);
  }
</script>

<main use:toc={{ observe: true }} on:tocinit={handleTocInit} on:tocchange={handleTocChange}>
  ...
</main>

Runtime Expectation

tocinit is only fired once. And whether tocchange is fired depends on the observe option (See Observing In View Element for more information). Specifically:

  • When observe is false, expect no tocchange CustomEvent. This makes sense because all necessary information has been extracted at initialization.
  • When observe is true, expect a tocchange CustomEvent that follows shortly after tocinit. The observe property of each extracted TocItem is only guaranteed to be populated in this tocchange event and not tocinit. This is because observe initialization operations are run asynchronously to avoid blocking any potential work with the extracted information from tocinit (such as rendering the table of content itself).

Toc Anchor

If not handled by @svelte-put/preprocess-auto-slug, toc will attempt to add an anchor tag to each matching element, similar to how Github adds anchor tags to headings in a rendered README.

<!-- input -->
<h2>Section Heading Level 2</h2>

<!-- output -->
<h2 id="section-heading-level-2" data-toc="">
  <a aria-hidden="true" tabindex="-1" href="#section-heading-level-2" data-toc-anchor="">#</a
  >Section Heading Level 2
</h2>

To customize how anchor tags are inserted (or disable it), specify the anchor option in use:toc parameter. It accepts a boolean or a config object with the TocAnchorConfig interface. Please refer to the its type definition here.

Observing "In View" Element

A common feature of a table of contents on the web is to track which part is “in view”. Traditionally this has been done with on:scroll, but with the relatively new IntersectionObserver on the scene, we can do this in a more performant way.

Caveat

Unfortunately, IntersectionObserver comes with its own caveat. For on:scroll, we can achieve something like:

For an element (typically heading), when it reach 10% offset of screen from the top, set it as active.

This is not trivial with IntersectionObserver without some hacking (to my knowledge at least), because IntersectionObserver triggers when element (or part of it) intersects with viewport. For this reason, toc prefers to “think” in terms of “section” rather than individual element, something like this:

When 80% of a section is visible within the viewport (threshold of 0.8 for IntersectionObserver), set it to active.

With this design decision, a typical solution is wrapping a heading tag and its associated content within a <section> or <div> (as shown in Quick Start).

<section>
  <h2>Heading, whether it is h1,h2,...</h2>
  <p>...content...</p>
</section>

Grouping content into sections as discussed above will help toc better track the active state as you scroll. But is is not mandatory; things will work just fine with flat headings and content; it will just be a bit less accurate.

You might also find that when an anchor, linked to its matching toc element, is clicked on (to scroll to said element), toc might not set that element as the active one. This is explained and an idiomatic solution is provided in Complementary TocLink.

Enabling

In toc, this feature is turned off by default. To use it, set the observe option to true or a config object.

<!-- use default options -->
<main use:toc={{ observe: true }}>...</main>

<!-- customization-->
<main use:toc={{
  observe: {
    strategy: 'auto',
    threshold: 1,
  }
}}>...</main>

Customization

It is recommended to use your code editor and language server for API discovery. But, should it be necessary, you can refer to the full type definition here.

As seen in Quick Start, at the table of contents section:

<section>
  <h2>Table of Contents</h2>
  {#if $tocStore.items.size}
    <ul>
      {#each $tocStore.items.values() as tocItem}
        <li>
          <!-- svelte-ignore a11y-missing-attribute -->
          <!-- eslint-disable-next-line svelte/valid-compile -->
          <a use:toclink={{ store: tocStore, tocItem, observe: true }} />
        </li>
      {/each}
    </ul>
  {/if}
</section>

Regarding markup, this is essentially the same as:

<section>
  <h2>Table of Contents</h2>
  {#if $tocStore.items.size}
    <ul>
      {#each $tocStore.items.values() as { id, text }}
        <li>
          <a href="#{id}" data-toc-link-active={$tocStore.activeItem?.id === id}>{text}</a>
        </li>
      {/each}
    </ul>
  {/if}
</section>

However,toclink does provide an additional click listener that makes sure the toc item being clicked on will be the active one, which is not guaranteed otherwise. This is because toc relies on IntersectionObserver, and when a matching toc element is scrolled into view, the next one might already intersects with viewport and become the active one.

In short, unless you need full control over the behavior of the anchor tag, it is recommended to use toclink for consistency and to save some manual effort.

Toc Data Attributes

On Toc Elements

Options provided to the toc action parameter, such as threshold or strategy, are global and affect all matching toc elements. Attributes listed below can be used to override behavior of toc per matching element. All of them are undefined by default.

interface TocElementDataAttributes {
	/** whether to ignore this element when searching for matching elements */
	'data-toc-ignore'?: boolean;
	/**
	 * the `id` to use for this element in `toc` context. If not provided, this
	 * will be the element `id`, or generated by `toc`
	 * if element does not have an `id` either.
	 */
	'data-toc-id'?: string;
	/**
	 * override the `strategy` for this element to use in creating
	 * `IntersectionObserver` This only has effect if the `observe`
	 * option is enabled in {@link TocParameters}
	 */
	'data-toc-strategy'?: TocObserveConfig['strategy'];
	/**
	 * override the `threshold` for this element to use in creating
	 * `IntersectionObserver` This only has effect if the `observe`
	 * option is enabled in {@link TocParameters}
	 */
	'data-toc-threshold'?: number;
}

Check code the source type definition here if necessary.

By Observe Operation

The following attributes are utilized by the observe operation when enabled. Notice some of them are readonly, which means they are handled internally by observe and should not be changed manually.

interface TocObserveDataAttributes {
	/**
	 * added to the element where IntersectionObserver is used when observe is
	 * turned on and references the associated toc element
	 */
	readonly 'data-toc-observe-for'?: string;
	/**
	 * added to toc root (the element where toc action is placed on) and
	 * references the id of the active matching element
	 *
	 * @remarks
	 *
	 * This attribute is reactive. When changed (either by toc or manually),
	 * it will trigger events and store update accordingly
	 */
	'data-toc-observe-active-id'?: string;
	/**
	 * added to toc root (the element where toc action is placed on) and
	 * indicate whether observe is being throttled, typically seen in conjunction
	 * with usage of the complementary toclink action
	 */
	readonly 'data-toc-observe-throttled'?: boolean;
	/**
	 * added to the element where toclink is used and
	 * set to true when the linked toc element is active
	 */
	readonly 'data-toc-link-active'?: boolean;
}

Check code the source type definition here if necessary.

Reference Markers

The following attributes act as readonly reference markers added by toc (or @svelte-put/preprocess-auto-slug).

interface TocReferenceMarkerDataAttributes {
	/**
	 * marking this element that it's been processed by toc
   *
	 * @remarks
	 *
	 * If this is already preprocessed by {@link https://svelte-put.vnphanquang.com/docs/preprocess-auto-slug | @svelte-put/preprocess-auto-slug},
	 * there will also be a `data-auto-slug` attribute.
	 */
	readonly 'data-toc'?: '';
	/**
	 * if the anchor option is enabled in toc parameters, this attribute is present on the injected anchor element.
   *
	 * @remarks
	 *
	 * If the element is already added by {@link https://svelte-put.vnphanquang.com/docs/preprocess-auto-slug | @svelte-put/preprocess-auto-slug},
	 * there `data-auto-slug-anchor` attribute is found instead.
	 */
	readonly 'data-toc-anchor'?: '';
	/**
	 * added to the element where toc action is used for internal reference
	 */
	readonly 'data-toc-root'?: '';
	/**
	 * added to the element where toclink action is used and references the linked toc element
	 */
	readonly 'data-toc-link-for'?: '';

	/**
	 * from {@link https://svelte-put.vnphanquang.com/docs/preprocess-auto-slug | @svelte-put/preprocess-auto-slug}
	 */
	'data-auto-slug'?: '';
	'data-auto-slug-anchor'?: '';
	'data-auto-slug-anchor-position'?: '';
}

Check code the source type definition here.


Happy making table of contents! 👨‍💻

Edit this page on Github