Table of Contents
How to create a Table of Contents UI (User Interface) widget.
Requirements
Common features of a table of contents widget are:
- ▪ a list of links, each relating to a section of content,
- ▪ clicking a link scrolls the page to the related section,
- ▪ when a section is in the viewport, the related link is highlighted and
- ▪ the list remains in the viewport, regardless of how far the user scrolled down.
Initial Render
We start by creating the elements for the initial render.
Make the body
a flex container and add two children:
- ▪ a
ul
on the left that will render the links and - ▪
main
on the right that will render each section of the page's content.
localhost:3000
<ul> <li> <a class="highlight" href="#one">1</a> </li> <li> <a href="#two">2</a> </li> <li> <a href="#three">3</a> </li> </ul> <main> <section id="one">1</section> <section id="two">2</section> <section id="three">3</section> </main>
Sticky
To keep the links in the viewport while the user is scrolling, add position: sticky; top: 0;
to the ul
.
By default, a flex container stretches its children to 100%
of its height.
This means the body
and ul
will be the same height.
As the body will be the scrolling element, this makes position: sticky
irrelevant.
To prevent the ul
from stretching, set align-self: flex-start
.
localhost:3000
ul { align-self: flex-start; position: sticky; top: 0; display: flex; flex-direction: column; }
Observe
To highlight a link when its related section scrolls into the viewport, we can use the Intersection Observer. In its options object:
- ▪ omit the
root
property to set the viewport as the root, - ▪ set
threshold: 0
to trigger the callback when1px
of a target element intersects the root and - ▪ set
rootMargin: "-50% 0px"
. Making the intersection area a small gap in the middle.
Observe each section
.
When 1 is scrolled into the middle of the viewport, the callback will be executed.
Use the callback to update which link is highlighted.
In the sandbox below, root
is set to document
.
This is only required because it is being rendered in an iframe.
localhost:3000
const links = document.querySelectorAll("a"); const sections = document.querySelectorAll("section"); const options = { // root is only required because this sandbox is in an iframe. root: document, rootMargin: "-50% 0px", threshold: 0, }; const HIGHLIGHT_CLASS = "highlight"; const tableOfConentsMap = [...sections].reduce( (acc, section, i) => ({ ...acc, [section.id]: links[i], }), {} ); let selectedId = sections[0].id; function removeHighlight(id) { tableOfConentsMap[id].classList.remove(HIGHLIGHT_CLASS); } function addHighlight(id) { tableOfConentsMap[id].classList.add(HIGHLIGHT_CLASS); } function onObserve(entries, observer) { entries.forEach((entry) => { if (entry.isIntersecting) { const { id } = entry.target; removeHighlight(selectedId); addHighlight(id); selectedId = id; } }); } const observer = new IntersectionObserver(onObserve, options); sections.forEach((section) => { observer.observe(section); });
Where to Next?


