The original mobile table of contents was a simple collapsible element that lived within the post content. This created several usability issues:
- Users had no sense of how much content remained other than implying it based on the length of the browser scrollbar
- Once you scrolled past the TOC, you lose the ability to quickly navigate to other sections of the post without scrolling back up to the inline TOC
- Mobile users should have the exact same experience as desktop users in terms of navigation, and as of now the desktop experience was better (due to the sticky aside TOC)
Building the sticky header system
Design-wise, the component is relatively simple, since it only includes a chevron to indicate expansion state, a circular progress indicator that fills as you scroll, and a dynamic text showing the current section (or a combination of sections, if multiple are visible at the same time).
Live scroll highlighting sucks
One of the more interesting problems I encountered was how to handle the highlighting of sections as you scroll. Of course, this applies to both mobile and desktop versions, but in this update I changed the implementation of both.
A naive implementation of live scroll highlighting would simply use an IntersectionObserver()
to watch for headers entering and exiting the viewport. The issue with this is that it doesn’t highlight anything if headers are no longer visible in your viewport, regardless of whether you’re in a section that “belongs” to a heading.
Example
Say that we have a post with the following structure:
## Part 1[500 lines of content]
## Part 2[500 lines of content]
If you were to scroll way past the first heading and was deep into the first section underneath it, the naive implementation would not highlight the first heading because it’s no longer in your viewport. This is unintuitive and a poor user experience. In a perfect world, if we were to view 250 lines of Part 1 and 250 lines of Part 2, then we would see both headings highlighted in the TOC and not need to make a decision about which heading to highlight.
I used to rely on jakelow/remark-sectionize, a remarkjs/remark plugin that retroactively generates <section>
tags based on the headers in the generated HTML. This would have done the following conversion:
# Forest elephants
## Introduction
In this section, we discuss the lesser known forest elephants.
## Habitat
Forest elephants do not live in trees but among them.
<section> <h1>Forest elephants</h1> <section> <h2>Introduction</h2> <p>In this section, we discuss the lesser known forest elephants.</p> </section> <section> <h2>Habitat</h2> <p>Forest elephants do not live in trees but among them.</p> </section></section>
However, this approach had pretty complicated issues involving section nesting and the fact that we didn’t have any control over its output other than by patching it. So, I decided to opt for a home-grown solution.
The concept of “jurisdictions”
The naming is interesting but I felt like it was the most intuitive to me. Basically, I created a system that assigns each heading a “territory” that extends from its position to the start of the next heading (or the end of the document):
function buildHeadingJurisdictions() { headingElements = Array.from( document.querySelectorAll('.prose h2, .prose h3, .prose h4, .prose h5, .prose h6') )
jurisdictions = headingElements.map((heading, index) => { const nextHeading = headingElements[index + 1] return { id: heading.id, start: heading.offsetTop, end: nextHeading ? nextHeading.offsetTop : document.body.scrollHeight } })}
- First, we collect all heading elements (
<h2>
through<h6>
) from the document’s prose content area using.querySelectorAll()
. - For each heading, we create a jurisdiction object that contains the heading’s
id
, the vertical position where this section begins (the heading’soffsetTop
value, which we namestart
), and the vertical position where this section ends (theoffsetTop
of the next heading or the bottom of the document if it’s the last heading, which we nameend
).
This creates a map of “territories” that each heading controls. This is crucial for accurately tracking which jurisdictions are currently visible as the user scrolls, even when the actual heading element itself is no longer in view.
The decision to show all visible sections
One of the more interesting decisions I made was to display all sections as comma-separated within the mobile TOC’s unexpanded state. This manifests as follows:
Example
Recall the previous example’s scenario:
## Part 1[500 lines of content]
## Part 2[500 lines of content]
If we saw 250 lines of Part 1 and 250 lines of Part 2, then the text snippet in the mobile TOC would read “Part 1, Part 2”.
The temptation is to implement a “smart” selection algorithm, perhaps showing the section with the most visible content, or the one closest to the viewport center, or to show the “deepest,” most specifically nested section. However, this creates numerous edge cases:
-
If you click to navigate to a short final section, it might never become the “primary” section because there isn’t enough content below it to scroll it to the top of the viewport.
-
As you scroll between sections, a “smart” selector might switch which section it considers primary at seemingly arbitrary points, creating a jarring experience.
-
When your viewport shows roughly equal amounts of two sections, any selection algorithm becomes essentially arbitrary.
By showing all visible sections, we give users complete awareness of their position in the document, eliminate the edge cases mentioned above, and create predictable behavior.
Progress indicator implementation
The circular progress indicator provides immediate visual feedback about reading progress without requiring any interaction:
function updateProgressCircle() { if (!progressCircleElement) return const scrollableDistance = document.documentElement.scrollHeight - window.innerHeight const scrollProgress = scrollableDistance > 0 ? Math.min(Math.max(window.scrollY / scrollableDistance, 0), 1) : 0 progressCircleElement.style.strokeDashoffset = ( PROGRESS_CIRCLE_CIRCUMFERENCE * (1 - scrollProgress) ).toString() }
The progress is calculated as a ratio of current scroll position to total scrollable distance, then applied as a stroke-dashoffset to create the filling effect.