Boise ID
00 00
Do you think it's going to rain today?
Building a best-practices table of contents for your blog: part 2

Introduction

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 structuremost likely a syntax tree.

If you haven't implemented that yet, part one offers one way to do it.

Principles

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 usableespecially 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

Left-rail navigation

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.

A mockup of what left-rail navigation may look like
A mockup of what left-rail navigation may look like

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:

A mockup of what navigation might look like on mobile
A mockup of what navigation might look like on mobile

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

The minimap component we'll build
The minimap component we'll build

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.

Demo

Tracking scroll 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.

useHeadingScrollTracker.ts
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 }

Building the minimap

Once the scroll progress is being tracked, all that's left is to build the UI of the minimap itself.

App.tsx
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

Minimap.tsx
"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

TableOfContents.tsx
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

styles.css
.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; }
MacCarrithers