@svelte-put/toc
Githubaction and utilities for building table of contents
Compatible with or powered directly bySvelte runes.
Still on Svelte 4? Seethe old docs site here.
Acknowledgement
This package relies onSvelte action and attempts to stay minimal. If you are looking for a declarative, component-oriented solution, check outjanosh/svelte-toc.
Installation
npm install --save-dev @svelte-put/tocpnpm add -D @svelte-put/tocyarn add -D @svelte-put/tocNew to Svelte 5? SeeMigration Guides.
Introduction
@svelte-put/toc operates atruntime and does the following:
search for matching elements (default:
:where(h1, h2, h3, h4, h5, h6)),generate
idattribute from elementtextContent,add anchor tag to element,
attachIntersectionObserver to each matching element to track its visibilityon the screen,
expose necessary pieces for building table of contents.
It is recommended to use the complementary@svelte-put/preprocess-auto-slug package for handling2 and3 atcompile time.toc will skip those operations if they are already handled bypreprocess-auto-slug.
The table of contents in this documentation site is generated bytoc itself. Check outits source code here (search fortoc).
Quick Start
Given the following Svelte source code, let's see howtoc 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 aTocinstance, powered by Svelte runes, whoseitemsproperty will be populated with the extracted toc elements and can trackactiveItemif theobserveoption is set totrue,- The associated
toc.actions.rootaction is placed on the parent whose descendants will be traversed to collect toc elements. Seetoc.actions.root for more details. - The associated
toc.actions.linkaction is placed on anchor tags within the table of contents to quickly setup clickable link to matching toc elements. Seetoc.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 theToc class is the first required step. It accepts aTocInit 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. VisitTocObserve andTocAnchor 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. InQuick 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 thattoc does not update when you change the action parameters at runtime and will require a page refresh to work again. This is because currentlytoc.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, pleaseraise an issue.
Toc Link
user:toc.actions.link is anoptional complementary action used on an<a>. It requires a mandatory parameter - aTocItem object (value oftoc.items), as seen inQuick 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
textContentof the element associated with theTocItemobject into the anchor tag, - set the
hrefattribute to theidof the element associated with theTocItemobject, - toggle on the
data-toc-link-activeattributewhen the element is in view, given theobserveoption is enabled uponToc 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 onIntersectionObserver, 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 usetoc.actions.link for consistency and conciseness. Further customization totoc.actions.link can be passed to theobserve.link config property of theToc instance. SeeObserving "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 thescroll event. With the relatively newIntersectionObserver on the scene, however, we can do this in a more performant manner.
By default, this feature is disabled. To turn it on, set theobserve option totrue duringToc 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. Foronscroll, 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 withIntersectionObserver without some hacking (to my knowledge at least), becauseIntersectionObserver 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.8forIntersectionObserver), 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 inQuick Start).
<section> <h2>Heading, whether it is h2,h3,...</h2> <p>...content...</p></section>Grouping content into sections as discussed above will helptoc track the active section more accurately, but it isNOT mandatory; things will work just fine with flat headings and content; it will just be atiny 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 tothe next section if it is not relevant to you.
There are three observe strategies use bytoc, set by the globalobserve.strategy property onToc instance config or per-element via thedata-toc-strategy attribute:
'self' |'parent' |'auto'By default,observe.strategy is set toauto (recommended), which relies on the following algorithm:
data-toc-strategytakes highest precedence and is used if set on the toc element, otherwise...- if
observe.strategyis 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
parentstrategy, else - forward the
selfstrategy,
- 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
parentorself, i.e.selfuses the matching element itself as the target, whileparentuses the parent element.
Similarly, the threshold forIntersectionObserver can be set via the globalobserve.threshold property onToc instance config or per-element via thedata-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 renderedREADME.
<!-- 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 viaanchor option in theToc instance config, which takes either aboolean (defaults totrue, set tofalse 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 totoc.activeItem andtoc.items, which are powered Svelte$state runes. Alternatively, you may listen totocinit ortocchangeCustomEvent on thetoc 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 whethertocchange is fired depends on theobserve option (SeeObserving In View Element for more information). Specifically:
- When
observeisfalse, expect notocchangeCustomEvent. This makes sense because all necessary information has been extracted at initialization. - When
observeistrue, expect atocchangeCustomEvent that follows shortly aftertocinit. Theobserveproperty of each extractedTocItemis only guaranteed to be populated in thistocchangeevent and nottocinit. This is becauseobserveinitialization 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 alldata-* attributes used bytoc. See the fulltype definition here.
On Toc Elements
Options provided to thetoc action parameter, such asthreshold orstrategy, are global and affect all matching toc elements. Attributes listed below can be used to override behavior oftoc per matching element. All of them areundefined 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 theobserve operation when enabled. Notice some of them arereadonly, which means they are handled internally byobserve 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 asreadonly reference markers added bytoc (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)
TheSvelte-store-basedTocStore interface has been dropped in favor for the newToc Class, which is now powered by Svelte runes, providing a much more minimal and powerful API. WhereasTocStore was optional, creating aToc instance is now a required step:
- start by replacing
createTocStorewithnew Toc(), - move the parameters passed to previously
tocaction to nowTocnewcall, - use the actions
toc.actions.rootandtoc.actions.linkinstead oftocandtoc-link, and - change references to
$tocStoretotoc
<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, theitems property ofTocStore andTocInitEventDetail is now aMap 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! 👨💻