@svelte-put/toc

GitHub Github

action and utilities for building table of contents

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

Still on Svelte 4? See the old docs site here.

Acknowledgement

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

Installation

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

New to Svelte 5? See Migration Guides.

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 compile time. toc will skip those operations if they are already handled by preprocess-auto-slug.

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

Quick Start

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

<script>
  import { Toc } from '@svelte-put/toc';
  const toc = new Toc({ observe: true });
</script>

<main use:toc.actions.root>
  <h1>Page Heading</h1>

  <section>
    <h2>Table of Contents</h2>
    {#if toc.items.size}
      <ul>
        {#each toc.items.values() as tocItem (tocItem.id)}
          <li>
            <!-- svelte-ignore a11y_missing_attribute -->
            <a use:toc.actions.link={tocItem}>
              <!-- textContent injected by toc -->
            </a>
          </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:

  • new Toc({ ... }) creates a Toc instance, powered by Svelte runes, whose items property will be populated with the extracted toc elements and can track activeItem if the observe option is set to true,
  • The associated toc.actions.root action is placed on the parent whose descendants will be traversed to collect toc elements. See toc.actions.root for more details.
  • The associated toc.actions.link action is placed on anchor tags within the table of contents to quickly setup clickable link to matching toc elements. See toc.actions.link for more details.

The output will look something like this:

<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 Class

Instantiate the Toc class is the first required step. It accepts a TocInit object with the following interface:

The code snippet below only show the top level config properties. Note that everything is optional, toc can be used without any parameter at all. Visit TocObserve and TocAnchor sections for their respective config interfaces.

export interface TocInit {
	/**
	 * the query selector used to find all matching
	 * DOM elements.
	 * Default to: `:where(h1, h2, h3, h4, h5, h6)`
	 */
	selector?: string;
	/**
	 * query selector(s) that match DOM elements to ignore
	 * Each selector is used as `:not(selector)`.
	 * Default to: `.toc-exclude`
	 *
	 * Alternatively, you can set the `data-toc-ignore` attribute on the element
	 * Default to: `[]`
	 */
	ignore?: string[] | string;
	/**
	 * inline `scroll-margin-top` value applied matching elements.
	 * Default to: `0`
	 */
	scrollMarginTop?: number | string | ((element: HTMLElement) => number | string);
	/**
	 * instructions to add the anchor tag.
	 * Default to: `true`
	 */
	anchor?: boolean | TocAnchorConfig;
	/**
	 * instructions to track the active element in the viewport using `IntersectionObserver`.
	 * Default to: `false`
	 */
	observe?: boolean | TocObserveConfig;
}

Actions

Toc Root

use:toc.actions.root is a required step that will search for matching elements from descendants of the element the action is attached to. In Quick Start, that's the <main> element.

<main use:toc.actions.root>

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 will require a page refresh to work again. This is because currently toc.actions.root 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.

user:toc.actions.link is an optional complementary action used on an <a>. It requires a mandatory parameter - a TocItem object (value of toc.items), as seen in Quick Start:

<section>
  <h2>Table of Contents</h2>
  {#if toc.items.size}
    <ul>
      {#each toc.items.values() as tocItem}
        <li>
            <!-- svelte-ignore a11y_missing_attribute -->
            <a use:toc.actions.link={tocItem}>
              <!-- textContent injected by toc -->
            </a>
        </li>
      {/each}
    </ul>
  {/if}
</section>

By default, it does the following:

  1. inject the textContent of the element associated with the TocItem object into the anchor tag,
  2. set the href attribute to the id of the element associated with the TocItem object,
  3. toggle on the data-toc-link-active attribute when the element is in view, given the observe option is enabled upon Toc instance creation.

Regarding markup, it is essentially the same as:

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

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

In short, unless you need full control over the behavior of the anchor tag, it is recommended to use toc.actions.link for consistency and conciseness. Further customization to toc.actions.link can be passed to the observe.link config property of the Toc instance. See Observing "In View" Element for more details.

Observing "In View" Element

A common feature of a table of contents on the web is to track which section is "in view". Traditionally this has been done by subscribing to the scroll event. With the relatively new IntersectionObserver on the scene, however, we can do this in a more performant manner.

By default, this feature is disabled. To turn it on, set the observe option to true during Toc instance creation...

import { Toc } from '@svelte-put/toc';
const toc = new Toc({ observe: true });

...or provide an object for verbose customization with the following interface:

/**
 * options to config how `toc` action create `IntersectionObserver` for each
 * matching toc element
 */
export interface TocObserveConfig extends Omit<IntersectionObserverInit, 'threshold'> {
	/**
	 * whether to add `IntersectionObserver` to each matching toc element
	 * to track active active element in the viewport.
	 * Default to: `true`
	 */
	enabled?: boolean;
	/**
	 * strategy to observe matching toc elements.
	 *
	 * - `'parent'` — observe the parent element of the matching toc element
	 *
	 * - `'self'` — observe the matching toc element itself
	 *
	 * - `'auto'` — attempt to compare matching toc element & its parent `offsetHeight` with
	 * `window.innerHeight` to determine the best strategy.
	 *
	 * Default to: `auto`
	 *
	 * Alternatively, this can be overridden per element by setting the `data-toc-strategy` attribute
	 * on that element.
	 */
	strategy?: 'parent' | 'self' | 'auto';
	/**
	 * threshold passed to `IntersectionObserver`.
	 * Default to: `(element) => Math.min((0.8 * window.innerHeight) / element.offsetHeight, 1)`
	 *
	 * Alternatively, `data-toc-threshold` (number) attribute can be set on
	 * the matching toc element
	 */
	threshold?: number | ((element: HTMLElement) => number);
	/**
	 * behavioral configuration for elements that `use:toc.actions.link={tocItem}` is placed on.
	 */
	link?: {
		/**
		 * whether to enable this configuration
		 * Default to: `false`
		 */
		enabled?: boolean;
		/**
		 * throttle the observe of `use:toc.actions.link` on click
		 *
		 * This ensures that the active toc item will be
		 * the same one that this link is pointing to.
		 * Otherwise, it is not guaranteed so, because `observe`
		 * is handled with `IntersectionObserver` the next items might
		 * already comes into viewport when this link is clicked.
		 *
		 * Set to 0 to disable throttling.
		 *
		 * Default to: `800`
		 */
		throttleOnClick?: number;
		/**
		 * boolean attribute(s) to indicate if this
		 * is linking to the active toc item
		 *
		 * For this to work, it is required that `tocItem` be provided
		 * or the href is in the form `'#<toc-item-id>'`
		 *
		 * By default, `toclink` uses {@link https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver | MutationObserver}
		 *
		 * Set `false` to disable this behavior
		 *
		 * Default to: `'data-toc-link-active'`
		 */
		activeAttribute?: string | string[] | boolean;
	};
}

Caveat

Although this may not have much impact on casual users, IntersectionObserver unfortunately comes with its own caveat. For onscroll, we can achieve something like:

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

This is not trivial with IntersectionObserver without some hacking (to my knowledge at least), because IntersectionObserver triggers callback when element (or part of it) intersects with the 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, the most "natural" pattern is to wrap heading tag and its associated content within a <section> (as shown in Quick Start).

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

Grouping content into sections as discussed above will help toc track the active section more accurately, but it is NOT mandatory; things will work just fine with flat headings and content; it will just be a tiny bit less accurate. This is especially helpful for setup that doesn't allow easily wrapping content in sections, such as markdown-based content.

Observe Strategies

This section discusses in details how to configure the strategy to observe toc elements. Feel free to skip to the next section if it is not relevant to you.

There are three observe strategies use by toc, set by the global observe.strategy property on Toc instance config or per-element via the data-toc-strategy attribute:

'self' | 'parent' | 'auto'

By default, observe.strategy is set to auto (recommended), which relies on the following algorithm:

  1. data-toc-strategy takes highest precedence and is used if set on the toc element, otherwise...
  2. if observe.strategy is set, use it, ...
  3. if strategy is now auto:
    • if the element's parent height is less than 80% of the viewport height, forward to the parent strategy, else
    • forward the self strategy,
  4. now the strategy is narrowed down to either parent or self, i.e. self uses the matching element itself as the target, while parent uses the parent element.

Similarly, the threshold for IntersectionObserver can be set via the global observe.threshold property on Toc instance config or per-element via the data-toc-threshold attribute.

Wrapping Toc Element in Anchor Tag

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.

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

<!-- html 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>

Configuration to how anchor tags are inserted (or not) can be specified via anchor option in the Toc instance config, which takes either a boolean (defaults to true, set to false to not insert tag)...

import { Toc } from '@svelte-put/toc';
const toc = new Toc({ anchor: false });

...or a config object with the following interface:

/**
 * options to config how `toc` action inject anchor tag for each matching toc element
 */
export interface TocAnchorConfig {
	/** whether to insert an anchor tag for each matching node */
	enabled?: boolean;
	/**
	 * where to create the anchor tag
	 *
	 * - 'prepend' — inject link before the target tag text
	 *
	 * - 'append' — inject link after the target tag text
	 *
	 * - 'wrap' — wrap the whole target tag text with the link
	 *
	 * - 'before' — insert link before the target tag
	 *
	 * - 'after' — insert link after the target tag
	 * Default to: 'prepend'
	 */
	position?: 'prepend' | 'append' | 'wrap' | 'before' | 'after';
	/**
	 * content of the inserted anchor tag,
	 * ignored when behavior is `wrap`.
	 * Default to: '#
	 */
	content?: string;
	/**
	 * href attribute of the inserted anchor tag
	 * Default to: `href: (id) => '#' + id`
	 */
	href?: (id: string) => string;
	/**
	 * properties set to the inserted anchor tag,
	 * Default to: `{ 'aria-hidden': 'true', 'tab-index': '-1' }`
	 */
	properties?: Record<string, string>;
}

CustomEvents

For side effects, you can subscribe to toc.activeItem and toc.items, which are powered Svelte $state runes. Alternatively, you may listen to tocinit or tocchange CustomEvent on the toc root element:

<script lang="ts">
  import { toc } from '@svelte-put/toc';
  import type { TocInitEventDetail, TocChangeEventDetail } from '@svelte-put/toc';

  const toc = new Toc({ observe: true });
  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.actions.root ontocinit={handleTocInit} ontocchange={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 Data Attributes

This section lists all data-* attributes used by toc. See the full type definition here.

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;
}

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
	 *
	 * This attribute is reactive. When changed (either by toc or manually),
	 * it will trigger events and update to Toc properties 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;
}

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
	 *
	 * 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.
	 *
	 * 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'?: '';
}

Migration Guides

V5 -> V6 (Svelte 5 in Runes mode)

The Svelte-store-based TocStore interface has been dropped in favor for the new Toc Class, which is now powered by Svelte runes, providing a much more minimal and powerful API. Whereas TocStore was optional, creating a Toc instance is now a required step:

  • start by replacing createTocStore with new Toc(),
  • move the parameters passed to previously toc action to now Toc new call,
  • use the actions toc.actions.root and toc.actions.link instead of toc and toc-link, and
  • change references to $tocStore to toc
<script>
  import { toc, createTocStore, toclink } from '@svelte-put/toc';
  import { Toc } from '@svelte-put/toc';

  const tocStore = createTocStore();
  const toc = new Toc({ observe: true });
</script>

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

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

Additionally, you should change the event directive syntax to just regular attributes (remove :):

<main use:toc={{ observe: true }} on:tocinit on:tocchange>
<main use:toc.actions.root ontocinit on:tocchange>

V4 -> V5

From 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>

Happy making table of contents! 👨‍💻

Edit this page on Github