Article
+ UpdatedCodex-Style TOC - Building a Magnetic Short-Line Table of Contents from Scratch
While tweaking the reading experience on my blog recently, I added a new table-of-contents component to the article page.
Normally it shows up as a set of short lines to the right of the article body, taking up no reading space. When the mouse moves over the TOC area, the tick closest to the pointer grows longer, nearby ticks fade back with distance, and the matching section title is shown.
To make the interaction feedback clearer, I also added a very light Tick sound when the active tick changes.
This article documents the full implementation of this table of contents.
Your browser does not support video playback
1. Where the TOC Effect Comes From
Honestly, I still don't know what to call this style of table of contents. It's certainly not an original style of mine, so for convenience let's just call it the short-line TOC for now.
The first place I saw this style was Notion. Their article TOC is made of short lines, and the section you're currently in is marked with a longer line. The moment I saw it I fell in love with this minimal but refined look, which fits my taste perfectly, so I built a similar Notion-style TOC into both Welight and Ornata. You can imagine how much I like it!
But the version on this blog differs from Notion's in some essential ways, mainly the focus effect of the TOC and the animation of the mouse interaction.
The inspiration actually comes from the TOC in Codex, the desktop app: a clean feel and smooth interaction animations that I really like.
Of course, while building this style I also folded in some ideas and details of my own, so you can think of it as a new version of the short-line TOC: Notion + Codex + my own customizations.
If you're browsing one of my blog posts, try looking to the right of the content, then move your mouse up and down over the TOC to experience the visuals and the interaction yourself. The sound is very crisp.
If you want to know how to build the same style of TOC, this article is for you. Enjoy the read!
2. Extracting and Identifying TOC Headings
Everyone's use case for a TOC may differ, so this part is mainly based on how I implemented it for my own blog, and you can skip it. Of course, if you also like my blog's style, take a look here: it's an open-source project, https://github.com/08820048/XuYi , and with a little configuration it works out of the box.
The article body is ultimately rendered as HTML.
The TOC component doesn't know which sections an article has, so the first step is to find the body container and extract the heading elements inside it.
const container = document.getElementById(containerId)
const headingElements = Array.from(
container.querySelectorAll<HTMLHeadingElement>('h1, h2, h3'),
).filter((heading) => heading.textContent?.trim())Only h1, h2, and h3 are extracted here.
h4 and h5 are not extracted, for two main reasons:
When a TOC goes too deep, the navigation structure becomes very fragmented.
The main job of this component is to help readers tell where they are in the article, not to fully display the document structure.
For an ordinary blog post, three heading levels are basically enough.
2.1 Generating Anchors for Headings
After a TOC item is clicked, the browser needs to know where to scroll to.
Therefore, every heading must have a unique id:
function slugifyHeading(text: string, index: number) {
const base = text
.trim()
.toLowerCase()
.replace(/[^\p{Letter}\p{Number}]+/gu, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48)
return `article-heading-${index + 1}${base ? `-${base}` : ''}`
}The generated result looks roughly like this:
1. Overall Architecture
↓
article-heading-1-1-overall-architectureInstead of using only the heading text, the heading's sequence number is added in front.
The reason is that different sections may use the same title, for example:
Notes
Implementation Details
NotesIf two elements have the same id, the browser can only jump to the first one when you click the TOC.
So a duplicate check is also needed:
const seenIds = new Map<string, number>()
const nextHeadings = headingElements.map((heading, index) => {
const text = heading.textContent?.trim() || ''
let id = heading.id.trim() || slugifyHeading(text, index)
const seenCount = seenIds.get(id) || 0
seenIds.set(id, seenCount + 1)
if (seenCount > 0) {
id = `${id}-${seenCount + 1}`
}
heading.id = id
return {
id,
text,
level: getHeadingLevel(heading.tagName),
}
})This way, even if headings are repeated, the final anchors are guaranteed to be unique.
2.2 Determining the Current Reading Section
Besides providing jumps, the TOC also needs to tell the reader where they currently are.
Here IntersectionObserver is used to watch whether a heading has entered the reading area:
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
visibleHeadings.set(
entry.target.id,
entry.boundingClientRect.top,
)
} else {
visibleHeadings.delete(entry.target.id)
}
}
if (visibleHeadings.size > 0) {
const nextActive = Array
.from(visibleHeadings.entries())
.sort((a, b) => a[1] - b[1])[0]?.[0]
if (nextActive) {
setActiveId(nextActive)
}
}
},
{
rootMargin: '-96px 0px -70% 0px',
threshold: [0, 1],
},
)rootMargin limits the area that actually counts to a small band near the top of the page.
This way only headings close to the reading position become the current section, rather than switching the moment a heading appears at the bottom of the screen.
If no heading is currently inside the observed area, look for the last heading that has already been scrolled past:
const passedHeading = headingElements
.filter((heading) => heading.getBoundingClientRect().top < 120)
.at(-1)
if (passedHeading?.id) {
setActiveId(passedHeading.id)
}The end result is:
In the normal state, the tick for the current section is shown in a darker color.
The current section's tick still keeps the default short-line length.
It doesn't conflict with the mouse hover animation.
3. The Idea Behind the Short-Line TOC
A traditional TOC usually shows text directly:
1. Overall Architecture
2. State Management
3. Routing Design
4. SummaryIn its default state, this TOC shows only short lines:
—
—
—
—
—The corresponding DOM structure is very simple:
<a
href={`#${heading.id}`}
className="article-toc__link"
>
<span className="article-toc__rule" />
<span className="article-toc__tooltip">
{heading.text}
</span>
</a>The short line itself is just an ordinary span:
.article-toc__rule {
display: block;
width: 0.38rem;
height: 1.5px;
border-radius: 999px;
background: currentColor;
opacity: 0.36;
}In the default state, all headings use the same length.
Heading level does not affect the line length, because this component aims for a quiet, uniform visual rhythm.
3.1 The Current Section Only Changes Color
The current reading section doesn't need to get longer, it only needs to get darker, a point I also mentioned earlier.
.article-toc__nav:not(:hover)
.article-toc__link[data-active='true']
.article-toc__rule {
background: var(--editor-ink);
opacity: 1;
}:not(:hover) is added here.
It means the current-section state is shown only when the user isn't interacting with the TOC.
Once the mouse enters the TOC, the visual focus goes to where the mouse is, avoiding two highlight centers at the same time.
4. Magnetic Tick Navigation as the Mouse Moves
This term is one I coined myself, though it may well have a proper name of its own. I'm too lazy to look it up, and it's only a name, so I won't dwell on it here.
Its core is not an ordinary hover, but computing the distance between each TOC item and the item under the mouse.
4.1 Recording the Item Under the Mouse
First, record the index of the item currently hovered:
const [hoveredIndex, setHoveredIndex] =
useState<number | null>(null)Each TOC item listens for the mouse entering:
<a
onMouseEnter={() => setHoveredIndex(index)}
onFocus={() => setHoveredIndex(index)}
>After the mouse leaves the whole TOC area, clear it:
<nav onMouseLeave={() => setHoveredIndex(null)}>onMouseLeave is placed on the whole TOC container here, not on each individual link.
Otherwise, an unnecessary flicker would occur in between when the mouse moves from one tick to the next.
Once it sits on the parent container, the neighbor effect stays on as long as the mouse is still within the TOC area.
4.2 Computing the TOC Distance
For each heading, compute the index distance between it and the mouse center:
const proximity =
hoveredIndex === null
? null
: Math.abs(index - hoveredIndex)For example, with the mouse at index 10:
Index Distance
7 3
8 2
9 1
10 0
11 1
12 2
13 3Then write the distance into the DOM:
data-proximity={
proximity !== null && proximity <= 3
? proximity
: undefined
}Finally, use CSS to control the line width for each distance:
/* Default length */
.article-toc__rule {
width: 0.38rem;
}
/* Third tick above/below, decayed back to the default */
[data-proximity='3'] .article-toc__rule {
width: 0.38rem;
}
/* Second tick above/below */
[data-proximity='2'] .article-toc__rule {
width: 0.76rem;
opacity: 0.52;
}
/* First tick above/below */
[data-proximity='1'] .article-toc__rule {
width: 1.18rem;
opacity: 0.72;
}
/* Mouse center */
[data-proximity='0'] .article-toc__rule {
width: 1.72rem;
height: 2px;
opacity: 1;
}The resulting visual structure is:
—
—
——
————
——————
————
——
—
—4.3 Why Use CSS Transition
Line width changes use CSS Transition rather than Keyframes:
.article-toc__rule {
transition:
opacity 160ms ease,
width 180ms cubic-bezier(0.2, 0, 0, 1),
height 180ms cubic-bezier(0.2, 0, 0, 1);
}This is because the mouse can change direction at any moment.
With fixed keyframe animations, when the user moves the mouse quickly, the old animation may not have finished before a new one starts over, which easily causes stutter visually.
CSS Transition can change its target value at any time, and this interruptibility suits magnetic navigation very well.
4.4 The Title Card Follows Only the Center Tick
Only the TOC item at distance 0 shows its title:
.article-toc__tooltip {
opacity: 0;
scale: 0.98;
translate: 0.35rem -50%;
}
[data-proximity='0'] .article-toc__tooltip {
opacity: 1;
scale: 1;
translate: 0 -50%;
}The title card uses absolute positioning, so it won't squeeze the TOC or the body either:
.article-toc__tooltip {
position: absolute;
left: 2.25rem;
top: 50%;
width: 14rem;
}5. Implementing the Sound Effect
After the visual feedback was done, I also added a very light Tick sound to TOC switching.
I'm using Cuelume here. Coincidentally, it's a project I came across on X recently. Cuelume differs a bit from ordinary sound libraries: it doesn't load MP3 or WAV files but synthesizes sound in real time through the Web Audio API. I found it quite interesting, and didn't expect it to come in handy so soon.
Initialize and bind it when the component mounts:
import { bind as bindCuelume } from 'cuelume'
useEffect(() => {
bindCuelume()
}, [])After that, Cuelume listens to the following on the page:
data-cuelume-hover="tick"When the mouse enters a new tick, a Tick is played. That's roughly the whole implementation record.
Copyright
Copyright of this article belongs to its author or rights holder. When reposting or quoting, please follow the applicable license terms and keep the link to this article.
Link to this article: https://xuyi.dev/en/2026-07-11-4rcwfj