This post will cover two main topics:
- A brief summary of UX best practices when building a table of contents for your articles
- and a look at how to implement a dynamic table of contents "minimap" to enhance mobile navigability of your content
You're reading the part two of a two-part series
Part one covers the process of generating the object for your table of contents from your post body. This post will assume you already have access to that data but it won't be specific to the particular implementation suggested by part one.
To make use of this article, you'll need to have some kind of object representing your heading structure—most likely a syntax tree.
If you haven't implemented that yet, part one offers one way to do it.
A generic table of contents serves a few specific purposes. It should:
- Give the readers a brief overview of the page's content
- Allow readers to navigate directly to sub-headings
- Allow readers to directly share sections of your content
There are many ways to design & implement a table of contents that meets these goals. The best implementation for your site will depend on your content and your readers.
I want to look at a more idealized table of contents, and what else could be done to make it more useful and usable—especially on mobile.
Here are some additional goals I envisioned when designing this blog:
- Be accessible to readers without scrolling to search for it
- Be equally as accessible on mobile without being obtrusive
- Subtle animations should indicate progress through the article
The most obvious solution for meeting all of these stated goals on desktop displays is left-rail navigation. It's both accessible and unobtrusive and readers are familiar with it as a pattern.
However, a small problem arises when trying to introduce this pattern to mobile devices: mobile devices don't have left rails because they're too narrow for columnar layouts.
A common fix is to move the left-rail navigation to the top of the page:
This is a perfectly good solution. However, one of my goals for the ideal table of contents was that it wouldn't require the reader to scroll all the way back up to the top of the article if they wanted to access it.
Since we can't fit a left-rail on mobile, and we can't have a static table of contents, what do we do?
The minimap is not a new concept; all programmers will recognize the concept from code editors. It's a small, interactive representation of the entire document. In this case, the content's headings are represented by minimal dashes whose length represents the hierarchy of the heading.
The dashes are illuminated and expanded as the reader scrolls through the page, which serves as a visual representation of the reader's progress.
Tracking the reader's progress through the article can be done with an IntersectionObserver
. The following React hook returns the ID of the heading that is currently intersecting with the viewport, and also returns null if the reader has scrolled past all headings.
import { useEffect, useState } from 'react'
export function useHeadingScrollTracker(ref: React.RefObject<HTMLElement>) {
const [activeId, setActiveId] = useState<string | null>(null)
if (!ref) throw new Error('useHeadingScrollTracker must be passed a ref')
useEffect(() => {
if (!ref.current) return
const target = ref.current
const headings = Array.from(
target.querySelectorAll('h1, h2, h3, h4, h5, h6')
);
if (headings.length === 0) return
const firstHeading = headings[0]
const handleIntersection = (entries: IntersectionObserverEntry[]) => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.target.id) {
setActiveId(entry.target.id)
}
})
}
const handleExitIntersection = (entries: IntersectionObserverEntry[]) => {
entries.forEach(entry => {
if (
!entry.isIntersecting &&
entry.boundingClientRect.bottom > window.innerHeight
) {
setActiveId(null)
}
})
}
const observerOptions = {
root: null,
rootMargin: '0% 0% -50% 0%',
threshold: 0.5
}
const exitObserverOptions = {
root: null,
rootMargin: '0% 0% 0% 0%',
threshold: 1
}
const observer = new IntersectionObserver(handleIntersection, observerOptions)
const exitObserver = new IntersectionObserver(handleExitIntersection, exitObserverOptions)
exitObserver.observe(firstHeading)
headings.forEach(heading => observer.observe(heading))
return () => {
exitObserver.unobserve(firstHeading)
headings.forEach(heading => observer.unobserve(heading))
observer.disconnect()
exitObserver.disconnect()
}
}, [ref])
return activeId
}
Once the scroll progress is being tracked, all that's left is to build the UI of the minimap itself.
import { useRef } from "react";
import { Post } from "./Post";
import { useHeadingScrollTracker } from "./useHeadingScrollTracker";
import "./styles.css";
import { Minimap } from "./Minimap";
export default function App() {
const wrapperRef = useRef<HTMLDivElement>(null);
const activeId = useHeadingScrollTracker(wrapperRef);
// For the purposes of this demo, we're hard-coding a possible heading heirarchy structure.
const postHierarchy = [
{
text: "First-level heading",
id: "heading-one",
children: [
{
text: "Second-level heading",
id: "heading-two",
children: [],
},
],
},
{
text: "First-level heading",
id: "heading-three",
children: [
{
text: "Second-level heading",
id: "heading-four",
children: [
{
text: "Third-level heading",
id: "heading-five",
children: [],
},
],
},
],
},
];
return (
<div className="App">
<div className="header">
<h1>Minimap demo</h1>
<h2>A new approach to a sticky table of contents for mobile devices</h2>
</div>
<div className="post-wrapper" ref={wrapperRef}>
<Post />
</div>
<Minimap headings={postHierarchy} activeId={activeId} />
</div>
);
}
Then a component which renders the minimap, and manages whether the flyout table of contents is being displayed
"use client";
import { useState } from "react";
import { TableOfContents } from "./TableOfContents";
export type PostHeading = {
text: string;
id: string;
children: PostHeading[];
};
interface MinimapProps {
headings: PostHeading[];
activeId: string | null;
}
export function Minimap({ headings, activeId }: MinimapProps) {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<div
className="minimap-rail"
onClick={() => setIsOpen((prev) => !prev)}
data-is-open={isOpen}
>
<div className="minimap">
{headings.map((heading) => (
<HeadingMarker
key={heading.id}
heading={heading}
activeId={activeId}
/>
))}
</div>
<div className="table-of-contents-wrapper">
<div className="close-button">x</div>
<div className="title">In this article</div>
<TableOfContents headings={headings} activeId={activeId} />
</div>
</div>
</>
);
}
interface HeadingMarkerProps {
heading: PostHeading;
activeId: string | null;
}
function HeadingMarker({ heading, activeId }: HeadingMarkerProps) {
return (
<>
<div className="marker" data-is-active={heading.id === activeId} />
{Boolean(heading.children.length) && (
<div className="level">
{heading.children.map((child) => (
<HeadingMarker key={child.id} heading={child} activeId={activeId} />
))}
</div>
)}
</>
);
}
And a component to render the table of contents
import type { PostHeading } from "./Minimap";
interface TableOfContentsProps {
headings: PostHeading[];
activeId: string | null;
}
export function TableOfContents({ headings, activeId }: TableOfContentsProps) {
return (
<nav className="table-of-contents">
<ol>
{headings.map((heading: PostHeading) => (
<HeadingLink key={heading.id} heading={heading} />
))}
</ol>
</nav>
);
}
function HeadingLink({ heading }: { heading: PostHeading }) {
return (
<li>
<a href={`#${heading.id}`}>{heading.text}</a>
{Boolean(heading.children.length) &&
heading.children.map((heading) => (
<HeadingLink key={heading.id} heading={heading} />
))}
</li>
);
}
And some styles to mix it all together
.minimap-rail {
position: absolute;
inset: 0;
z-index: 999;
pointer-events: none;
}
.minimap {
top: 20lvh;
position: sticky;
width: 2rem;
left: 100%;
pointer-events: all;
background: black;
padding: 1rem 0.6rem;
padding-left: 0.3rem;
border-radius: 0.3rem;
}
.marker {
border-radius: 2px;
background: #ccc;
height: 3px;
margin-left: 0.2rem;
margin-bottom: 0.9rem;
transition: background-color 0.2s, margin-left 0.2s;
}
.minimap > *:last-child .marker:last-child {
margin-bottom: 0;
}
.marker[data-is-active="true"] {
background: white;
margin-left: 0;
}
.level {
padding-right: 0.3rem;
}
.table-of-contents-wrapper {
pointer-events: none;
width: 300px;
margin: auto;
position: sticky;
top: 20lvh;
left: 0;
right: 0;
background: #ccc;
padding: 4rem;
opacity: 0;
transform: translateX(5rem) scale(0.9);
transition: all 0.25s;
border-radius: 1rem;
}
.table-of-contents-wrapper .title {
margin-bottom: 2rem;
font-weight: bold;
}
.table-of-contents-wrapper .close-button {
position: absolute;
top: 1.5rem;
right: 1.5rem;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background: salmon;
border-radius: 9999rem;
}
.minimap-rail[data-is-open="true"] {
pointer-events: all;
}
.minimap-rail[data-is-open="true"] .table-of-contents-wrapper {
opacity: 1;
pointer-events: all;
transform: translateX(0) scale(1);
}
.table-of-contents ol {
list-style: none;
padding: 0;
}
.table-of-contents li {
padding: 1rem;
}