@svelte-put/toc
GithubCompatible with or powered directly by Svelte runes.
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 elementtextContent
,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 aToc
instance, powered by Svelte runes, whoseitems
property will be populated with the extracted toc elements and can trackactiveItem
if theobserve
option is set totrue
,- 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:
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.
Toc Link
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:
- inject the
textContent
of the element associated with theTocItem
object into the anchor tag, - set the
href
attribute to theid
of the element associated with theTocItem
object, - toggle on the
data-toc-link-active
attribute when the element is in view, given theobserve
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
forIntersectionObserver
), 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:
data-toc-strategy
takes highest precedence and is used if set on the toc element, otherwise...- if
observe.strategy
is set, use it, ... - 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,
- if the element's parent height is less than 80% of the viewport height, forward to the
- now the strategy is narrowed down to either
parent
orself
, i.e.self
uses the matching element itself as the target, whileparent
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
isfalse
, expect notocchange
CustomEvent. This makes sense because all necessary information has been extracted at initialization. - When
observe
istrue
, expect atocchange
CustomEvent that follows shortly aftertocinit
. Theobserve
property of each extractedTocItem
is only guaranteed to be populated in thistocchange
event and nottocinit
. This is becauseobserve
initialization operations are run asynchronously to avoid blocking any potential work with the extracted information fromtocinit
(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
withnew Toc()
, - move the parameters passed to previously
toc
action to nowToc
new
call, - use the actions
toc.actions.root
andtoc.actions.link
instead oftoc
andtoc-link
, and - change references to
$tocStore
totoc
<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! 👨💻