Scroll chapters
A table of contents for a long page, built by itself from the titles of the page: every h2 of the marked block opens a chapter, and the list follows when a title is added, removed or reworded.
Documentation
-
Copy to Webflow
Add Geist and Geist Mono to your site's fonts (Site settings, Fonts, Google Fonts) before pasting: Webflow removes any font your site has not installed. -
Add custom CSS
Custom CSS in WebflowThis CSS already ships inside the Webflow embed, so a pasted section needs nothing added. Paste it yourself, in the page or the site custom code, only if the effect runs on several pages. The tutorial explains where.
See the tutorialThe look of the preview is thescroll-chapters-*classes of the paste: the size of the number, the type of the list, the two columns. The Embed holds what moves, and reads these variables:--scroll-chapters-accent(the small square beside the line being read, and the number of the strip),--scroll-chapters-quiet(the strength of the ink of what is not being read, 42%),--scroll-chapters-pin,--scroll-chapters-shift,--scroll-chapters-top, and for the strip of a narrow window--scroll-chapters-strip,--scroll-chapters-strip-ink,--scroll-chapters-barand--scroll-chapters-gutter. Set them on the block, or on anything above it, in a rule of your own. No parent of the table may cut its overflow withoverflow: hidden, or it stops being held beside the text. -
Add custom JS
Custom JavaScript in WebflowThis script already ships inside the Webflow embed, settings block included. You paste it yourself, before the closing body tag of the page or the site custom code, only when the effect has to run on several pages, and the tutorial says how to choose.
See the tutorial -
Use it on your own elements
How to add a custom attribute in WebflowA custom attribute is a pair, a name and a value, typed in the Settings panel of the element you selected, under Custom attributes. The name is what the script looks for, the value is what it reads. The tutorial shows the panel.
See the tutorialCustom attributesSelect the element on the canvas and open the Settings panel. Under Custom attributes, add a pair: the name on the left, the value on the right.Custom attribute What it does data-scroll-chapters Marks the block that holds the text and its table of contents. It takes no value: leave the value field empty. Every title in it that matches the setting headings, every h2 by default, opens a chapter. The settings below go on the same element. data-scroll-chapters-nav On the table of contents, inside the block. It takes no value. It is the part held beside the text on a wide window, and the sheet at the foot of a narrow one. Name it for screen readers with aria-label, "Chapters" in the paste. Without it the block is left alone and the engine says so in the console. [ Required ] data-scroll-chapters-list On the box that holds the lines of the list and nothing else, inside the table. It takes no value. The engine writes one line in it for each title it finds; the lines written by hand are what shows on the canvas and without JavaScript. [ Required ] data-scroll-chapters-item On the lines of the list: a link, or a block that holds one. It takes no value. The first one is the model of every line the engine writes: style that one line and every line follows. The other lines written by hand may carry the mark too, as they do in the preview; the engine replaces them all. Without it the engine writes plain links. [ Optional ] data-scroll-chapters-item-number In the model line, on the element that receives the number of the chapter, 01. It takes no value. [ Optional ] data-scroll-chapters-item-title In the model line, on the element that receives the title of the chapter. It takes no value. Without it the title goes in the link itself. [ Optional ] data-scroll-chapters-number On an element that receives the number of the chapter being read, on wheels that turn with the scroll. It takes no value. Its class gives the size of the figures. [ Optional ] data-scroll-chapters-total On an element that receives how many chapters there are, 06. It takes no value. [ Optional ] data-scroll-chapters-left On an element that receives the minutes of reading left, a figure alone: the words around it, min left, are yours. It takes no value. [ Optional ] data-scroll-chapters-title="Short name" On a title of the page: the wording the list uses for it, when the title itself is too long for the list. [ Optional ] data-scroll-chapters-skip On a title of the page that must not open a chapter. It takes no value. [ Optional ] data-scroll-chapters-state Written by the engine on a line of the list: current on the chapter being read, read on the ones before it. The block carries data-scroll-chapters-mode (side or strip) and data-scroll-chapters-ready, the table data-scroll-chapters-in while the block is on screen and data-scroll-chapters-open while its sheet is up. Never written by hand; the stylesheet reads them, and so can a rule of yours. [ Optional ] SettingsEvery setting is a custom attribute too, added the same way and on the same element. An attribute always wins over the defaults written in the code.Attribute Default What it is data-scroll-chapters-headings h2 The titles that open a chapter, as a CSS selector read inside the block. Options: h2, the default; h2, h3 to list two levels; .chapter-title or [data-chapter] for titles of your own. data-scroll-chapters-line 0.4 The height in the window where a chapter becomes the one being read, as a share of the window: 0 its top, 1 its foot. data-scroll-chapters-offset clamp(72px, 14vh, 140px) Where the title of a chosen chapter lands, measured from the top of the window, in any CSS length. data-scroll-chapters-roll 0.3 The stretch of scroll over which the number turns before the next title, as a share of the window height. data-scroll-chapters-speed 230 The reading speed the minutes left are worked out from, in words a minute. data-scroll-chapters-duration 1.1 The time of the travel to a chosen chapter, in seconds: the page, the square of the list and the number move together over it. data-scroll-chapters-strip 900 The width of the window, in px, under which the table of contents leaves the side of the text and docks as a strip at the foot of the window. data-scroll-chapters-hash on Whether the chosen chapter is written in the address of the page. Options: on, a line chosen by the visitor writes the id of its title after the #, without adding a step to the history; off, the address is left alone.
Two files and their pictures (8), no build step.
It runs in the browser only. Load it with a script tag in plain HTML, in a
client-side script in Astro, never in the frontmatter. In React, call
ScrollChapters.mount(ref.current) in useEffect and
destroy() in its cleanup.
-
Tune it, either way
Per element with an attribute, or once for the whole site by editingDEFAULTSat the top of the file.Attribute Default What it is Lower Higher data-scroll-chapters-headings h2 The titles that open a chapter, as a CSS selector read inside the block. Options: h2, the default; h2, h3 to list two levels; .chapter-title or [data-chapter] for titles of your own. data-scroll-chapters-line 0.4 The height in the window where a chapter becomes the one being read, as a share of the window: 0 its top, 1 its foot. The next chapter takes over later, once its title is near the top of the window The next chapter takes over sooner, while its title is still low in the window; over 0.6 the list runs ahead of the reading data-scroll-chapters-offset clamp(72px, 14vh, 140px) Where the title of a chosen chapter lands, measured from the top of the window, in any CSS length. The title lands closer to the top. Give it at least the height of a bar held at the top of the page More of the end of the chapter before stays in sight above the title data-scroll-chapters-roll 0.3 The stretch of scroll over which the number turns before the next title, as a share of the window height. The number turns late and fast, like a click; 0 makes it roll once the title has arrived The number starts to turn long before the title arrives; over 0.6 it is rarely still data-scroll-chapters-speed 230 The reading speed the minutes left are worked out from, in words a minute. More minutes for the same page: a slow reader, or a dense text Fewer minutes: 230 is an adult reading prose, 300 someone skimming data-scroll-chapters-duration 1.1 The time of the travel to a chosen chapter, in seconds: the page, the square of the list and the number move together over it. Quicker; under 0.6 the number has no time to be seen turning; 0 jumps Slower and more shown; over 1.6 the page feels late on the click data-scroll-chapters-strip 900 The width of the window, in px, under which the table of contents leaves the side of the text and docks as a strip at the foot of the window. The table stays beside the text on narrower windows: set it where your two columns stop fitting The strip is used on wider windows; a very large value uses it everywhere data-scroll-chapters-hash on Whether the chosen chapter is written in the address of the page. Options: on, a line chosen by the visitor writes the id of its title after the #, without adding a step to the history; off, the address is left alone. -
Drive it from JavaScript
When a script follows the chapter being read, an analytics call or a picture that changes with the chapter, or sends the page to a chapter.const element = document.querySelector('[data-scroll-chapters]'); element.addEventListener('scrollchapters:change', (e) => { console.log(e.detail); }); // Follow the chapter being read. element.addEventListener('scrollchapters:change', (event) => { console.log(event.detail.index, event.detail.title); }); const toc = ScrollChapters.mount(element); // the block auto() mounted, or a new one toc.go('glass'); // by the id of a title, or by a number counted from 0 toc.progress(); // { index, chapter, page, minutes } // When the page leaves: ScrollChapters.destroy(element); // removes what the engine added, gives the markup back as it was
-
Fit it to your page
The chapters
- A chapter is a title and what follows it, up to the next title. No wrapper is needed around a chapter, and none is in the way: the engine reads the titles, wherever they are in the block.
- To add a chapter, write a title. The list, the total and the minutes follow, also when the title arrives later from a script or a CMS.
- **Give each title an
id** in the page's own words (pricing, notsection-2): it is what the address shows and what a link elsewhere points at. A title without one gets anidmade from its words. - Three chapters or fifteen. A list taller than the window scrolls on its own, with no bar, and keeps the line being read in sight.
- A long title wraps in the list and takes its ink line after line. To shorten it in the list only, add
data-scroll-chapters-titleto the title. - What comes before the first title belongs to no chapter: the number shows
01and the first line waits, quiet. Its words count in the minutes left.
The table of contents
It is plain markup, and everything in it is optional but the list: a large number, a total, the minutes left, in any order and any layout. The lines written in the list are what a page without JavaScript shows, and what the Designer canvas shows; the engine replaces them with one line for each title it finds, built on the first one.
On a narrow window
Under the width given by
stripthe table leaves the flow and becomes a sheet in--scroll-chapters-stripunder the foot of the window. Only its bar shows: the number, in the accent, the title being read with its ink, and how many chapters there are. The number and the title roll together to the next chapter. Pressing the bar raises the sheet, with everything the table holds; a line, the cross, Escape or a touch outside lowers it. The strip is away while the block is not on screen, so it never sits over a footer or a hero.The keyboard
Tab goes through the lines of the list; Enter travels to the chapter and moves the focus to its title, so the next Tab carries on from there. On a narrow window Tab reaches the bar, Enter or Space raises the sheet, Tab goes on into the list, and Escape lowers the sheet and gives the focus back to the bar. The line being read carries
aria-current="location".The colours and the sizes
Custom properties, set at zero specificity on the block. Give them your own values on the block or on anything above it:
--scroll-chapters-accent: the small square beside the line being read, and the number of the strip.--scroll-chapters-quiet: the strength of the ink of what is not being read, as a percentage, 42% in the preview. The colour itself is the colour of the list.--scroll-chapters-pin: the side of the square, 5px.--scroll-chapters-shift: how far the square hangs to the left of the list, in the margin.--scroll-chapters-top: the distance kept between the top of the window and the table held beside the text.--scroll-chapters-stripand--scroll-chapters-strip-ink: the ground and the text of the strip and of its sheet.--scroll-chapters-bar: the height of the bar of the strip.--scroll-chapters-gutter: the padding of the sheet on its sides.
The type, the size of the number and the spacing of the list are the classes of the table itself.
From JavaScript
ScrollChapters.mount(element, overrides)returns the instance of a block, mounting it if it was not:{ element, settings, chapters(), index(), progress(), go(which, options), next(), prev(), open(), close(), refresh(), destroy() }.gotakes a number counted from 0, theidof a title or the title element, and{ instant: true }to jump.ScrollChapters.auto(root)mounts every marked block insideroot, and runs once on its own;ScrollChapters.destroy(element)gives the markup back.progress()returns{ index, chapter, page, minutes }: the chapter being read, how far through it and through the whole from 0 to 1, and the minutes left.- Events, all bubbling from the block:
scrollchapters:ready;scrollchapters:changewhen another chapter becomes the one being read, with{ index, previous, heading, title }indetail;scrollchapters:openandscrollchapters:closefor the sheet. - The state is in the markup:
data-scroll-chapters-stateon a line,currentorread;aria-current="location"on the link being read;data-scroll-chapters-modeon the block,sideorstrip;data-scroll-chapters-inanddata-scroll-chapters-openon the table;data-scroll-chapters-readyon the block. - **Under
prefers-reduced-motion: reduce** the number changes at once, a chosen chapter is reached with no travel, and the sheet appears without rising. The ink still follows the scroll, since the reader moves it. - Without JavaScript the table is the list of links written in the page, in the flow, above or beside the text.
- The essay of the demo was written for it and its photographs come from Unsplash: on a real page every title and every word is the user's own.
-
Avoid the pitfalls
- The table of contents is inside the marked block, next to the text. The engine does not look for it elsewhere on the page.
- **
stripis your own breakpoint.** It is a width of the window, and the layout of the two columns is yours: set it to the width where the table and the text stop fitting side by side, 900 in the preview. - No parent of the table may cut its overflow with
overflow: hiddenorauto: the table stops being held beside the text. Useoverflow-x: clipwhere a parent has to cut. And nothing above it may carry a transform or a filter, or the strip is no longer held to the window. - The table is held inside the marked block, for as long as that block scrolls by: the block has to be as tall as the text, which it is when it holds the text. The stylesheet hangs the table from the top of the block itself (
align-self: flex-start); do not stretch it to the height of the block, or it has nowhere to be held. - The page must scroll in the window, not in a wrapper with
overflow: auto. - A page that ends with its last chapter cannot bring the last titles up to the reading line: on the last screen of the page the line slides down to the foot of the window, so every chapter is reached, the last ones quickly. A chosen chapter that cannot be brought to
offsetstops where the page ends. - A bar held at the top of the page covers a title that lands under it: give
offsetat least its height. - The ink is a gradient clipped to the letters, in the colour of the line at two strengths. A
colorset on the line reaches it; a-webkit-text-fill-coloror abackgroundof your own on the title does not go with it. A browser withoutcolor-mix()shows every title at full strength, and the square alone says which one is being read. - A chapter chosen in the list stays the one shown until the reader moves the page, also when the page could not bring its title up to
offsetbecause it ends first. - In Webflow, do not use a Rich Text element's own table of contents or an interaction on the same list: the engine rewrites the lines of the list. The titles may live in a Rich Text element: set
headingstoh2as usual. - **
idused twice on a page** breaks the address and the travel: the link goes to the first element that carries it. - In React or any framework that owns the DOM, mount in an effect and call
destroy()in its cleanup; the engine rewrites the children of the list, adds the bar of the strip to the table, and writes attributes.
One click on Copy the AI prompt copies everything an assistant needs to build this effect: the complete code, the markup, the dependency, every setting and every pitfall.
What it does
Scroll chapters is an animated navigation component for Webflow and vanilla JavaScript, with no library. Also called table of contents, scrollspy table of contents or sticky table of contents with reading progress.
A table of contents for a long page, built by itself from the titles of the page: every h2 of the marked block opens a chapter, and the list follows when a title is added, removed or reworded. Beside the text, on a wide window, it holds a large number that is geared to the scroll: it stands still while a chapter is read and turns to the next figure, like the wheel of a counter, over the last stretch before the next title. In the list every title is quiet but the one being read, which has a small square of the accent hung beside it and takes its full ink from its first letter to as far as the chapter has been read, line after line for a long title. A chapter chosen in the list is reached in one move: the page travels, the square goes from one line to the other, the number rolls straight from the figure it shows to the one it reaches, and nothing between the two lights up on the way; a page that rushes by changes nothing in the list until it slows down. A small counter gives the minutes of reading left, from the words of the page. A click or Enter on a line travels to its chapter, eased, and the reader takes the page back at any time. On a narrow window the table docks as a strip at the foot of the window, with the number and the title being read rolling on the same wheel, and rises as a sheet with the whole list when it is pressed. Titles short or long, three chapters or fifteen, pictures that load late: the places are measured again whenever the block changes size. Without JavaScript the list written in the page is a plain list of links. Reduced motion respected. No library. Pattern type table of contents, reading progress, scrollspy, chapter navigation.
Updated
Questions
Paste the block, or mark the block that holds your text data-scroll-chapters and put inside it a table marked data-scroll-chapters-nav with a list marked data-scroll-chapters-list. Every Heading 2 of the block becomes a line of the table of contents, also inside a Rich Text element, and the list follows when a heading is added, removed or reworded.
Yes. The title of the chapter being read takes its full ink from its first letter to as far as the chapter has been read, a small square of the accent hangs beside it, and its link carries aria-current="location". A large number marked data-scroll-chapters-number turns to the next figure like a counter wheel as the next heading arrives.
Yes. An element marked data-scroll-chapters-left receives the minutes of reading left, worked out from the words of the page that are still below the reading line. The reading speed is the setting speed, 230 words a minute by default: data-scroll-chapters-speed="200" for a denser text.
Under the width given by data-scroll-chapters-strip, 900 px by default, the table of contents leaves the side of the text and docks as a strip at the foot of the window, with the number and the title being read. Pressing the strip raises a sheet with the whole list; a line, Escape or a touch outside lowers it.
Set data-scroll-chapters-offset on the block to at least the height of the header, in any CSS length: it is where the title of a chosen chapter lands, measured from the top of the window. The travel takes duration seconds, and the list goes straight to the chosen chapter without lighting the ones in between.