Left ArrowBack


Table of Contents

A web page wireframe showing a table of contents & sections of content.

Table of Contents


Status: seed

How to create a Table of Contents UI (User Interface) widget.

Initial Render

Common features of a Table of Contents widget are:

  • a list of links, each relating to a section of content,
  • the list remains in the viewport, regardless of scroll position,
  • clicking a link scrolls the page to the related section &
  • when a section is in the viewport, the related link is highlighted.

To create a Table of Contents, 1st create the elements for the initial render. Make the body a flex container & add 2 children:

  • a ul on the left that will render the list of links &
  • main on the right that will render each section of the page's content.
An isometric view of the DOM. Showing the viewport, a list of links on the left & a list of sections on the right.


      <a class="highlight" href="#one">1</a>
      <a href="#two">2</a>
      <a href="#three">3</a>
   <section id="one">1</section>
   <section id="two">2</section>
   <section id="three">3</section>


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 & 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.

An isometric view of the DOM. Showing the viewport, a list of links on the left & a list of sections on the right.


ul {
   align-self: flex-start;
   position: sticky;
   top: 0;

   display: flex;
   flex-direction: column;


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 when 1px of a target element intersects the root &
  • set rootMargin: "-50% 0px". This makes the root's intersection area a small gap in the middle.
A wireframe of the viewport showing 2 shaded areas at the top & bottom

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.


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) => ({
      [section.id]: links[i],

let selectedId = sections[0].id;

function removeHighlight(id) {

function addHighlight(id) {

function onObserve(entries, observer) {
   entries.forEach((entry) => {
      if (entry.isIntersecting) {
         const { id } = entry.target;
         selectedId = id;

const observer = new IntersectionObserver(onObserve, options);

sections.forEach((section) => {

Where to Next?

A sci-fi robot taxi driver with no lower body
└── JavaScript
└── Web API
└── Intersection Observer
├── Dynamic Header
├── Infinite Scroll
Arrow pointing down
└── Table of Contents