Sticky responsive table of contents component. Live Demo
npm install --dev svelte-toc
<script>
import Toc from 'svelte-toc'
</script>
<Toc />
<main>
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
<h2>Next Section</h2>
<h3 class="toc-exclude">Another Subsection</h3>
</main>
Full list of props and bindable variables for this component (all of them optional):
-
activeHeading: HTMLHeadingElement | null = null
The DOM node of the currently active (highlighted) heading (based on user's scroll position on the page).
-
activeHeadingScrollOffset: number = 100
Distance in pixels to top edge of screen at which a heading jumps from inactive to active. Increase this value if you have a header that makes headings disappear earlier than the viewport's top edge.
-
activeTocLi: HTMLLIElement | null = null
The DOM node of the currently active (highlighted) ToC item (based on user's scroll position on the page).
-
aside: HTMLElement | undefined = undefined
The DOM node of the outer-most
aside
element. This is the element that gets thetoc
class. Cannot be passed in as a prop, only for external access! -
blurParams: BlurParams | undefined = { duration: 200 }
Parameters to pass to
transition:blur
fromsvelte/transition
. Set tonull
or{ duration: 0 }
to disable blurring. -
breakpoint: number = 1000
At what screen width in pixels to break from mobile to desktop styles.
-
desktop: boolean = true
true
if current window width >breakpoint
elsefalse
. -
flashClickedHeadingsFor: number = 1500
How long (in milliseconds) a heading clicked in the ToC should receive a class of
.toc-clicked
in the main document. This can be used to help users immediately spot the heading they clicked on after the ToC scrolled it into view. Flash duration is in milliseconds. Set to 0 to disable this behavior. Style.toc-clicked
however you like, though less is usually more. For example, the demo site uses:is(h2, h3, h4) { transition: 0.3s; } .toc-clicked { color: cornflowerblue; }
-
getHeadingIds = (node: HTMLHeadingElement): string => node.id
Function that receives each DOM node matching
headingSelector
and returns the string to set the URL hash to when clicking the associated ToC entry. Set tonull
to prevent updating the URL hash on ToC clicks if e.g. your headings don't have IDs. -
getHeadingLevels = (node: HTMLHeadingElement): number => Number(node.nodeName[1]) // get the number from H1, H2, ...
Function that receives each DOM node matching
headingSelector
and returns an integer from 1 to 6 for the ToC depth (determines indentation and font-size). -
getHeadingTitles = (node: HTMLHeadingElement): string => node.textContent ?? ``
Function that receives each DOM node matching
headingSelector
and returns the string to display in the TOC. -
headings: HTMLHeadingElement[] = []
Array of DOM heading nodes currently listed and tracked by the ToC. Is bindable but mostly meant for reading, not writing. Deciding which headings to list should be left to the ToC and controlled via
headingSelector
. -
headingSelector: string = `:is(h2, h3, h4):not(.toc-exclude)`
CSS selector that matches all headings to list in the ToC. You can try out selectors in the dev console of your live page to make sure they return what you want by passing it into
[...document.querySelectorAll(headingSelector)]
. The default selector:is(h2, h3, h4):not(.toc-exclude)
excludesh5
andh6
headings as well as any node with a class oftoc-exclude
. For example<h2 class="toc-exclude">Section Title</h2>
will not be listed. -
hide: boolean = false
Whether to render the ToC. The reason you would use this and not wrap the component as a whole with Svelte's
{#if}
block is so that the script part of this component can still operate and keep track of the headings on the page, allowing conditional rendering based on the number or kinds of headings present (see PR#14). To access the headings<Toc>
is currently tracking, use<Toc bind:headings />
. -
autoHide: boolean = true
Whether to automatically hide the ToC when it's empty, i.e. when no headings match
headingSelector
. If true, ToC also automatically un-hides itself when re-querying for headings (e.g. on scroll) and finding some. -
keepActiveTocItemInView: boolean = true
Whether to keep the active ToC item in view when scrolling the page. Only applies to long ToCs that are too high to fit on screen. If true, the ToC container will scroll itself to keep the active item in view and centered (if possible). Requires
scrollend
event browser support (71% as of 2024-01-22), with Safari the only major browser lacking support. -
minItems: number = 0
Completely prevent the ToC from rendering if it doesn't find at least
minItems
matching headings on the page. The default of 0 means the ToC will always render, even if it's empty. -
nav: HTMLElement | undefined = undefined
The DOM node of the
nav
element. Cannot be passed in as a prop, only for external access! -
open: boolean = false
Whether the ToC is currently in an open state on mobile screens. Can be used to externally control the
open
state through 2-way binding. This value is ignored on desktop. -
openButtonLabel: string = `Open table of contents`
What to use as ARIA label for the button shown on mobile screens to open the ToC. Not used on desktop screens.
-
pageBody: string | HTMLElement = `body`
Which DOM node to use as the
MutationObserver
root node. This is usually the page's<main>
tag or<body>
element. All headings to list in the ToC should be children of this root node. Use the closest parent node containing all headings for efficiency, especially if you have a lot of elements on the page that are on a separate branch of the DOM tree from the headings you want to list. -
scrollBehavior: 'auto' | 'smooth' = `smooth`
Whether to scroll the page smoothly or instantly when clicking on a ToC item. Set to
'auto'
to use the browser's default behavior. -
title: string = `On this page`
ToC title to display above the list of headings. Set
title=''
to hide. -
titleTag: string = `h2`
Change the HTML tag to be used for the ToC title. For example, to get
<strong>{title}</strong>
, settitleTag='strong'
. -
tocItems: HTMLLIElement[] = []
Array of rendered Toc list items DOM nodes. Essentially the result of
document.querySelectorAll(headingSelector)
. Can be useful for binding. -
warnOnEmpty: boolean = true
Whether to issue a console warning if the ToC is empty.
To control how far from the viewport top headings come to rest when scrolled into view from clicking on them in the ToC, use
/* replace next line with appropriate CSS selector for all your headings */
:where(h1, h2, h3, h4) {
scroll-margin-top: 50px;
}
Toc.svelte
has 3 named slots:
-
slot="toc-item"
to customize how individual headings are rendered inside the ToC. It has access to the DOM node it represents vialet:heading
as well as the list indexlet:idx
(counting from 0) at which it appears in the ToC.<Toc> <span let:idx let:heading slot="toc-item"> {idx + 1}. {heading.innerText} </span> </Toc>
-
slot="title"
: Title shown above the list of ToC entries. Propstitle
andtitleTag
have no effect when filling this slot. -
slot="open-toc-icon"
: Icon shown on mobile screens which opens the ToC on clicks.
The HTML structure of this component is
<aside>
<button>open/close (only present on mobile)</button>
<nav>
<h2>{title}</h2>
<ol>
<li>{heading1}</li>
<li>{heading2}</li>
...
</ol>
</nav>
</aside>
Toc.svelte
offers the following CSS variables which can be passed in directly as props:
aside.toc
z-index: var(--toc-z-index, 1)
: Applies on both mobile and desktop.
aside.toc > nav
overflow: var(--toc-overflow, auto)
min-width: var(--toc-min-width)
max-width: var(--toc-desktop-max-width)
width: var(--toc-width)
max-height: var(--toc-max-height, 90vh)
: Height beyond which ToC will use scrolling instead of growing vertically.padding: var(--toc-padding, 1em 1em 0)
font-size: var(--toc-font-size)
aside.toc > nav > ol > .toc-title
padding: var(--toc-title-padding)
margin: var(--toc-title-margin)
aside.toc > nav > ol
list-style: var(--toc-ol-list-style, none)
padding: var(--toc-ol-padding, 0)
margin: var(--toc-ol-margin)
aside.toc > nav > ol > li
border-radius: var(--toc-li-border-radius)
padding: var(--toc-li-padding, 2pt 4pt)
margin: var(--toc-li-margin)
border: var(--toc-li-border)
color: var(--toc-li-color)
aside.toc > nav > ol > li:hover
color: var(--toc-li-hover-color, cornflowerblue)
: Text color of hovered headings.background: var(--toc-li-hover-bg)
aside.toc > nav > ol > li.active
color: var(--toc-active-color, white)
: Text color of the currently active heading (the one nearest but above top side of current viewport scroll position).background: var(--toc-active-bg, cornflowerblue)
font-weight: var(--toc-active-font-weight)
border: var(--toc-active-border)
border-width: var(--toc-active-border-width)
: Allows setting top, right, bottom, left border widths separately.border-radius: var(--toc-active-border-radius, 2pt)
aside.toc > button
color: var(--toc-mobile-btn-color, black)
: Menu icon color of button used as ToC opener on mobile.background: var(--toc-mobile-btn-bg, rgba(255, 255, 255, 0.2))
: Background of padding area around the menu icon button.padding: var(--toc-mobile-btn-padding, 2pt 3pt)
border-radius: var(--toc-mobile-btn-border-radius, 4pt)
aside.toc.mobile
bottom: var(--toc-mobile-bottom, 1em)
right: var(--toc-mobile-right, 1em)
aside.toc.mobile > nav
width: var(--toc-mobile-width, 18em)
background: var(--toc-mobile-bg, white)
: Background color of thenav
element hovering in the lower-left screen corner when the ToC was opened on mobile screens.box-shadow: var(--toc-mobile-shadow)
border: var(--toc-mobile-border)
aside.toc.desktop
margin: var(--toc-desktop-aside-margin)
: Margin of the outer-mostaside.toc
element on desktops.
aside.toc.desktop > nav
margin: var(--toc-desktop-nav-margin)
top: var(--toc-desktop-sticky-top, 2em)
: How far below the screen's top edge the ToC starts being sticky.background: var(--toc-desktop-bg)
Example:
<Toc
--toc-desktop-aside-margin="10em 0 0 0"
--toc-desktop-sticky-top="3em"
--toc-desktop-width="15em"
/>
Statements | Branches | Lines |
---|---|---|
Here are some steps to get you started if you'd like to contribute to this project!