@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 strategy and attempts to stay minimal. If you are looking for a declarative, component-oriented solution, check out Janosh 's svelte-toc .

Installation

terminal

npm install --save-dev @svelte-put/toc

Migration Guide

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

Migration: items from Object to Map

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

  • 1

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

  • 2

    generate id attribute from element textContent,

  • 3

    add anchor tag to element,

  • 4

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

  • 5

    expose necessary pieces for building table of contents.

It is recommended to use the complementary @svelte-put/preprocess-auto-slug for handling 2 and 3 at build time when possible. 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 on:scroll vs IntersectionObserver.

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

Quick Start

Given the following svelte component, let's see how toc does its job.

quick start - input

<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 usage of createTocStore as a helper for creating an idiomatic svelte store , which will populate its items property with the extracted toc elements, and track activeItem if observe is set to true.

Also note the complementary optional toclink used on anchor tags in the table of contents. This helps save some manual effort and keep the behavior consistent with the main toc action. See Complementary toclink for more details.

quick start - output

<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 where it is used. In the Quick Start example, 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 requires a page refresh to work again. This is because currently toc only runs once on mount.

Supporting dynamic update is quite a heavy task (tracking what's changed and avoiding duplicated operations) that will increase the bundle size & complexity but is not very 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 submit an issue .

Events

In Quick Start , svelte store is used to keep code minimal. Alternatively, you can listen for tocinit and tocchange events.

toc events

<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 action parameter (discussed in Observing 'In View' Element ).

  • When observe is false, expect no tocchange event. This makes sense because all necessary information has been extracted at initialization.
  • When observe is true, expect a tocchange event 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).

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 , 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 use case is wrapping heading tags within a section or div (as shown in Quick Start ).

think in terms of section

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

rather than flat individual elements

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

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 toclink .

Observe Customization

In toc, this feature is turned off by default. To use it, set the toc action parameter observe to true or provide an object with customization options.

turn on observe feature

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

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

observe.strategy

Type: 'parent' | 'self' | 'auto'

Default: 'auto'

This option affects which element the IntersectionObserver will observe.

  • parent: observe parentElement 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.

Alternatively, data-toc-strategy can be set on the matching toc element to override this global settings.

observe.threshold

Type: number | ((element: HTMLElement) => number)

Default: (element) => Math.min((0.8 * window.innerHeight) / element.offsetHeight, 1)

The threshold passed to IntersectionObserver .

Alternatively, data-toc-threshold can be set on the matching toc element to override this global settings.

observe.enabled

Type: boolean

Default: false

Whether to turn on observe feature. When observe is provided as an object, this is true implicitly.

As seen in Quick Start :

toclink

<a use:toclink={{ store: tocStore, tocItem, observe: true }}></a>

Regarding markup, this is essentially the same as:

toclink - markup equivalence

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

<main use:toc={{ store: tocStore, observe: true }}>
  <!-- ... -->
  <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>
  <!-- ... -->
</main>

However, toclink provides an additional click listener that makes sure the toc item being clicked on will be the active one.

This is not always guaranteed otherwise (when not using toclink), because toc relies on IntersectionObserver, and when a matching toc element is scrolled into view, the next one might already intersects with the viewport and become the active one.

Toc Data Attributes

On Toc Elements

Attributes listed below can be used to override behavior of toc per matching element. All of them are undefined by default.

Name

Description

data-toc-id

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
type: string
mutability: customizable

data-toc-ignore

Whether to ignore this element when searching for matching elements
type: boolean
mutability: customizable

data-toc-strategy

Override the strategy for this element to use in creating IntersectionObserver. This only has effect if the observe option is enabled in toc parameters
type: 'parent' | 'self' | 'auto'
mutability: customizable

data-toc-threshold

Override the threshold for this element to use in creating IntersectionObserver. This only has effect if the observe option is enabled in toc parameters
type: number
mutability: customizable

Below instructions show how to add type support for these attributes

app.d.ts

/// <reference types="svelte" />

import type { TocDataAttributes } from '@svelte-put/toc';

declare namespace svelteHTML {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-interface
  interface HTMLAttributes<T> extends TocDataAttributes {}
}

By Observe Operation

The following attributes are utilized by the observe operation when enabled.

Name

Description

data-toc-observe-for

Added to the element where IntersectionObserver is used when observe is turned on and references the associated toc element
type: string
mutability: readonly

data-toc-observe-active-id

Added to toc root (the element where toc action is placed on) and references the id of the active matching element
type: string
mutability: customizable

This attribute is reactive. When changed (either by toc or manually), it will trigger events and store updates accordingly

data-toc-observe-throttled

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
type: boolean
mutability: readonly

data-toc-link-active

Added to the element where toclink is used and set to true when the linked toc element is active
type: boolean
mutability: readonly

Reference Markers

The following attributes act as readonly reference markers.

Name

Description

data-toc

Marking this element that it's been processed by toc
If this is already preprocessed by @svelte-put/preprocess-auto-slug , there will also be a data-auto-slug attribute.

data-toc-anchor

If the anchor option is enabled in toc parameters, this attribute is present on the injected anchor element.
If the element is added by @svelte-put/preprocess-auto-slug , the data-auto-slug-anchor can be seen instead.

data-toc-root

Added to the element where toc action is used for internal reference

data-toc-link-for

Added to the element where toclink action is used and references the linked toc element

cat table of contents

Happy making table of contents! 👨‍💻

Edit this page on GitHub