@svelte-put/toc
action and utilities for building table of contents
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 elementtextContent
,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
isfalse
, expect notocchange
event. This makes sense because all necessary information has been extracted at initialization. - When
observe
istrue
, expect atocchange
event 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).
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
forIntersectionObserver
), 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
: observeparentElement
of the matching toc element,self
: observe the matching toc element itself,auto
: attempt to compare matching toc element & its parentoffsetHeight
withwindow.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.
toclink
Action
Complementary 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
Happy making table of contents! 👨💻