Table of contents · ~5 KB gzip · zero dependencies
Chapterize
One attribute turns the headings already on your page into a live, accessible table of contents. It highlights the section being read, glides a marker between links, and works the same in every framework.
<nav data-chapter data-chapter-build data-chapter-marker></nav>Overview #
Long pages are hard to navigate. Readers lose their place, jump back to the top to scan the outline and miss the section they came for. Chapterize JS keeps a live outline next to the content and tells the reader, at a glance, where they are and how much is left.
It does two jobs. It builds a nested table of contents from the headings already in your DOM, giving every heading without an id a readable slug. Then it spies on those sections and marks the matching link as current while the reader scrolls. There is no JavaScript configuration: everything is an HTML attribute.
- Attribute-only.
data-chapteron a nav is the whole setup. Ids are generated, sticky headers are read from your CSSscroll-padding-top. - Accessible first.
aria-current="location", an automatic landmark name, focus that follows clicks, and reduced motion honoured live. - Correct at the edges. Short final sections each get their turn, the first click lands, CSS
scroll-behavior: smoothdoes not fight it. - Framework-proof. It watches the DOM, so client-side routes, re-renders and late content just work. Safe to import during SSR.
- A marker built in.
data-chapter-markerdraws the gliding line you see here, from a CSP-safe constructed stylesheet in a cascade layer.
Install #
One package, no stylesheet to load, no peer dependencies. Use whichever entry suits the site.
From npm #
npm install chapterize-jsImport the auto entry once in your client bundle. It spies every [data-chapter] nav now and in the future (it watches the DOM), and does nothing on the server.
import 'chapterize-js/auto';With a script tag #
No tooling at all? The global build registers Chapterize and initialises itself when the DOM is ready. Add data-manual to the tag to call Chapterize.init() yourself.
<script src="https://unpkg.com/chapterize-js" defer></script>Quick start #
Add an empty nav where the outline should go. data-chapter-build with no value reads the h2 and h3 headings inside <main>; give it a selector to choose others. Each level nests under the one before it.
<nav data-chapter data-chapter-build="article h2, article h3" data-chapter-marker></nav>Already have a list of links? Point them at your section ids and put data-chapter on the container. Every same-page link inside it is matched to its target, including /page#id links from a router.
<nav data-chapter aria-label="Guide">
<a href="#intro">Intro</a>
<a href="#setup">Setup</a>
<a href="#usage">Usage</a>
</nav>Clicking a link scrolls to the section, writes the hash without a jump, and moves keyboard focus to the section so the next Tab continues from there.
Attributes #
Everything is configured on the nav. Attributes are live: change one and the nav updates.
data-chapter- Turns the element into a scrollspy. Required.
data-chapter-build- Builds a nested
<ol>from headings. Empty:main h2, main h3. A value: your own selector. data-chapter-marker- Draws the built-in marker.
line(default),pillorminimal. data-chapter-offset- Pixels hidden by a sticky header. Default: the page's
scroll-padding-top. data-chapter-hash="false"- Do not write
#idto the URL on click. Hash routers (#/,#!) are detected and left alone. data-chapter-label- On a heading: the text to use in the outline instead of the heading's own.
data-chapter-ignore- On a heading or a container: keep it out of the built outline.
Sticky headers #
If a sticky header covers the top of the page, set scroll-padding-top on html to its height, which you probably want anyway for #hash links. Chapterize JS reads it, so clicks land below the header and the activation line moves down to match. A heading's own scroll-margin-top is respected too.
Choosing the active section #
The active section is the last one whose top has crossed a line a quarter of the way down the viewport, below the offset. That matches what people perceive as "the section I am reading". Sections too short to reach the line at the bottom of the page share the last stretch of scrolling, so each one is highlighted in turn and the last one at the very bottom.
JavaScript API #
Optional. The attributes cover the common cases; the API is there for apps that want control.
import { init, chapterjs } from 'chapterize-js';
const toc = init(); // every [data-chapter] nav, now and later; idempotent
const one = chapterjs(navElement, { build: 'article h2', marker: 'pill' });
one.go('install'); // scroll to a section by id or index
one.marker('minimal'); // switch presets, or false to remove
one.refresh(); // re-measure after layout changes it cannot see
one.destroy(); // undo everything it addedEvents #
When the active link changes, the nav dispatches a bubbling chapter:change event:
document.addEventListener('chapter:change', (e) => {
const { index, id, link, target, previous } = e.detail;
document.title = `${link.textContent} · Docs`;
});The marker #
With data-chapter-marker the library draws the marker itself: a faint rail, a fill up to the active link, a glowing line that eases between links and a soft band behind the active row. No stylesheet to load. The styles are a constructed stylesheet, so they work under a strict style-src 'self' Content Security Policy, and they sit in @layer chapter, so any rule you write wins.
Presets #
Three looks, one attribute: line (this page), pill (a rounded highlight glides behind the active link) and minimal (just the line). See them side by side.
Styling #
Every visual reads a custom property on the nav, including the timing:
.toc {
--chapter-color: #a5b4ff; /* line, glow, fill */
--chapter-color-2: #f0abfc; /* second gradient stop */
--chapter-duration: .45s; /* glide time */
--chapter-ease: cubic-bezier(.16, 1, .3, 1);
}Prefer to draw your own? Leave the attribute off. The nav still gets --chapter-y, --chapter-h, --chapter-x and --chapter-w (the active link's box) and --chapter-progress (0 to 1). The active link gets .is-active, its parent entries .is-trail.
Frameworks #
Chapterize JS is plain DOM, so it works with any framework or none. Import it once, and its DOM watching handles route changes, re-rendered headings and navs that mount later. Importing during server rendering is a no-op.
Accessibility #
The active link gets aria-current="location", announced as "current location". A nav without a name is labelled by its own heading if it has one, or "On this page" otherwise.
Motion #
When the reader asks for reduced motion, clicks jump instantly, even if your CSS sets scroll-behavior: smooth, and the marker moves without gliding. Both follow the setting live, with no reload.
Focus #
After a click, focus moves to the section heading, so keyboard and screen-reader users continue reading from there instead of being stranded in the sidebar. When the outline is longer than its container, the active link is kept in view inside the nav without moving the page.
Compared #
The accessible, attribute-only alternative to Tocbot. Coming from Gumshoe, which has not been updated since 2019? The markup is the same idea, with the outline built for you.
| Setup | Builds TOC | aria-current | Reduced motion | |
|---|---|---|---|---|
| Chapterize JS | Attributes | Yes, with ids | Yes | Yes, live |
| Tocbot | JS config | Needs ids | No | No |
| Gumshoe | JS | No | No | No |
| Bootstrap ScrollSpy | Attributes | No | No | Partly |
FAQ #
Does it work without JavaScript? A hand-written list of links is an ordinary table of contents without the script. A built outline needs it.
Can I have more than one? Yes. This page runs two: the sidebar and the compact mobile bar.
What about the native CSS scroll markers? Chrome's scroll-target-group is promising but Chromium-only, and it has no events, no aria-current and no marker geometry. Chapterize JS is the cross-browser version today; the plan is to use the native feature where it helps once it ships more widely.