renrizzolo/blog

posts / Creating a dynamic Table Of Contents React component

Creating a dynamic Table Of Contents React component

Creating a dynamic Table Of Contents React component
Thursday February 2nd, 2023 / 1 minute read

Table Of Contents sidebars are a useful feature for blogs, lengthy pages and documentation.

Pre-requisites

Each heading within the article/page should have a unique id. In my case I'm using gatsby-remark-autolink-headers to automate this.

<h2 id="heloooo-there">Heloooo There</h2>
<p>.....</p>
<h2 id="heloooo-there">Heloooo There</h2>
<p>.....</p>

Creating a list of anchors that link to these ids will jump the page to that specific region. This is nice, but we can build upon it by highlighting the currently active item.

<ul>
  <li>
    <a href="heloooo-there">Heloooo There</a>
  </li>
</ul>
<ul>
  <li>
    <a href="heloooo-there">Heloooo There</a>
  </li>
</ul>

Basic layout

There's not much to it in terms of layout. Here's a contrived example:

<article style="display: flex; flexDirection: row;">
  <section style="flex: 1;">
    <h2 id="heading-1">Heading 1</h2>
    <!-- paragraphs -->
    <h2 id="heading-2">Heading 2</h2>
    <!-- paragraphs -->
  </section>
  <aside style="width: 300px;">
    <ul>
      <li>
        <a href="#heading-1">Heading 1</a>
      </li>
      <li>
        <a href="#heading-2">Heading 2</a>
      </li>
    </ul>
  </aside>
</article>
<article style="display: flex; flexDirection: row;">
  <section style="flex: 1;">
    <h2 id="heading-1">Heading 1</h2>
    <!-- paragraphs -->
    <h2 id="heading-2">Heading 2</h2>
    <!-- paragraphs -->
  </section>
  <aside style="width: 300px;">
    <ul>
      <li>
        <a href="#heading-1">Heading 1</a>
      </li>
      <li>
        <a href="#heading-2">Heading 2</a>
      </li>
    </ul>
  </aside>
</article>

Data structure

Ideally you have an object you can loop through to render the heading links. In this example, title is the heading content, and url is the id: [{ title: "Heading 1", url: "#heading-1" }].

If you don't have access to such data, you could create it directly from the DOM.

const headings = Array.from(document.querySelectorAll("article h2")); // or whatever selector make sense for your markup
const headingItems = headings.map((el) => ({
  url: `#${el.id}`,
  title: el.innerText,
}));
const headings = Array.from(document.querySelectorAll("article h2")); // or whatever selector make sense for your markup
const headingItems = headings.map((el) => ({
  url: `#${el.id}`,
  title: el.innerText,
}));

With this data we can just map it to create our list:

<TableOfContents headings={mdx.tableOfContents.items} />;
 
//  ...
 
const TableOfContents = ({ headings }) => {
  return (
    <aside>
      <ul>
        {headings.map(({ title, url }) => (
          <li>
            <a href={url}>{title}</a>
          </li>
        ))}
      </ul>
    </aside>
  );
};
<TableOfContents headings={mdx.tableOfContents.items} />;
 
//  ...
 
const TableOfContents = ({ headings }) => {
  return (
    <aside>
      <ul>
        {headings.map(({ title, url }) => (
          <li>
            <a href={url}>{title}</a>
          </li>
        ))}
      </ul>
    </aside>
  );
};

At this point, we have a simple T.O.C which links to headings on the page.

Making it dynamic

Let's make a hook, useObserveActiveSection, that highlights the currently visible heading section.

We're using an Intersection Observer, to be notified when the heading element enters the viewport based on certain conditions.

Here's what we'll do to observe the elements - I'll get to observer itself next.

headings.forEach((heading) => {
  // get the id without the #
  const id = heading.url.substring(1);
  const el = document.getElementById(id);
  // if the heading exists in the document
  if (el) {
    // pass it to the intersection observer
    observer.observe(el);
  }
});
headings.forEach((heading) => {
  // get the id without the #
  const id = heading.url.substring(1);
  const el = document.getElementById(id);
  // if the heading exists in the document
  if (el) {
    // pass it to the intersection observer
    observer.observe(el);
  }
});

IntersectionObserver

We'll wrap this and the instatiation of the IntersectionObserver in a useEffect.

// a ref to store the window y position
const previousY = React.useRef()
 
React.useEffect(() => {
  // create the observer with a callback function and options
  const observer = new IntersectionObserver(
    // callback function
    (entries, observer) => {
      // get the current scroll position
      const currentY = window.scrollY;
      entries.forEach((entry) => {
        // intersection successfully observed!
        if (entry.isIntersecting) {
          // set some state for our currently visible heading
          setHighlighted(entry.target.id);
        } else {
          // get the current heading item
          const thisIndex = headings.findIndex(
            ({ url }) => url === `#${entry.target.id}`
          );
          // highlight previous section when scrolling up
          // if the window scrollY is less than the last time we recorded it
          // we're scrolling up
          if (currentY < previousY.current) {
            if (thisIndex > 0 && thisIndex < headings.length) {
              const url = headings[thisIndex - 1]?.url;
              setHighlighted(url.substring(1));
            }
          }
        }
        // update the ref
        previousY.current = currentY;
      });
    },
    // options object
    {
      // trigger the callback as soon as the target is visible
      threshold: 0,
      root: document,
      // like a css margin (top, right, bottom, left)
      // for calculating the bounding box of the root element
      // This is basically treating the top 15% of the viewport
      // as the intersection area. This way, a heading won't become active
      // as soon as it appears on the bottom of the screen.
      rootMargin: "0% 0% -85% 0%"
    }
    },[headings]);
 
// a ref to store the window y position
const previousY = React.useRef()
 
React.useEffect(() => {
  // create the observer with a callback function and options
  const observer = new IntersectionObserver(
    // callback function
    (entries, observer) => {
      // get the current scroll position
      const currentY = window.scrollY;
      entries.forEach((entry) => {
        // intersection successfully observed!
        if (entry.isIntersecting) {
          // set some state for our currently visible heading
          setHighlighted(entry.target.id);
        } else {
          // get the current heading item
          const thisIndex = headings.findIndex(
            ({ url }) => url === `#${entry.target.id}`
          );
          // highlight previous section when scrolling up
          // if the window scrollY is less than the last time we recorded it
          // we're scrolling up
          if (currentY < previousY.current) {
            if (thisIndex > 0 && thisIndex < headings.length) {
              const url = headings[thisIndex - 1]?.url;
              setHighlighted(url.substring(1));
            }
          }
        }
        // update the ref
        previousY.current = currentY;
      });
    },
    // options object
    {
      // trigger the callback as soon as the target is visible
      threshold: 0,
      root: document,
      // like a css margin (top, right, bottom, left)
      // for calculating the bounding box of the root element
      // This is basically treating the top 15% of the viewport
      // as the intersection area. This way, a heading won't become active
      // as soon as it appears on the bottom of the screen.
      rootMargin: "0% 0% -85% 0%"
    }
    },[headings]);
 
 
    headings.forEach((heading) => {
      // get the id without the #
      const id = heading.url.substring(1);
      const el = document.getElementById(id);
      // if the heading exists in the document
      if (el) {
        // pass it to the intersection observer
        observer.observe(el);
      }
    });
 
    headings.forEach((heading) => {
      // get the id without the #
      const id = heading.url.substring(1);
      const el = document.getElementById(id);
      // if the heading exists in the document
      if (el) {
        // pass it to the intersection observer
        observer.observe(el);
      }
    });
 
 
  // clean up the observer
  return () => observer.disconnect();
}, [headings]);
 
 
  // clean up the observer
  return () => observer.disconnect();
}, [headings]);

Here's a visualization of the intersection observer in action, using the `intersection-observer-debugger package.

The purple box at the top is our viewport with the negative bottom rootMargin making it the top 15% of the viewport. When the observed element enters this area, the observer is triggered with that element's entry having the isIntersecting property.

Adding the active class

Next up we'll add a separate effect to apply some styling.

// this is where we record the current heading id
const [highlighted, setHighlighted] = React.useState();
 
React.useEffect(() => {
  if (highlighted) {
    // find the Table Of Contents Link.
    // (This could be scoped to a class or a ref of the TOC container element.)
    const navElement = document.querySelector(`a[href="#${highlighted}"]`);
    // remove all other active links when highlighting a new one
    headings.forEach(
      ({ url }) =>
        url !== `#${highlighted}` &&
        document
          .querySelector(`a[href="${url}"]`)
          ?.classList.contains("active") &&
        document.querySelector(`a[href="${url}"]`)?.classList.remove("active")
    );
    // add the 'active' class to the current highlighted link
    navElement &&
      !navElement.classList.contains("active") &&
      navElement.classList.add("active");
  }
}, [highlighted, headings]);
// this is where we record the current heading id
const [highlighted, setHighlighted] = React.useState();
 
React.useEffect(() => {
  if (highlighted) {
    // find the Table Of Contents Link.
    // (This could be scoped to a class or a ref of the TOC container element.)
    const navElement = document.querySelector(`a[href="#${highlighted}"]`);
    // remove all other active links when highlighting a new one
    headings.forEach(
      ({ url }) =>
        url !== `#${highlighted}` &&
        document
          .querySelector(`a[href="${url}"]`)
          ?.classList.contains("active") &&
        document.querySelector(`a[href="${url}"]`)?.classList.remove("active")
    );
    // add the 'active' class to the current highlighted link
    navElement &&
      !navElement.classList.contains("active") &&
      navElement.classList.add("active");
  }
}, [highlighted, headings]);

All this DOM manipulation doesn't feel very React-y. There's nothing wrong with it, but you could definitely finesse it with refs and Context.

Complete implementation

Let's put it together and see it in action!

This version is getting the headings data directly from the DOM via a known class (.article-h2).

import React from "react";
import { Box, Text, Grid } from "theme-ui";

const useObserveActiveSection = ({ headings }) => {
  const previousY = React.useRef();
  const [highlighted, setHighlighted] = React.useState();

  React.useEffect(() => {
    if (highlighted) {
      const navElement = document.querySelector(
        'a[href="#' + highlighted + '"]'
      );
      console.log(highlighted, navElement);
      headings.forEach(
        ({ url }) =>
          url !== "#" + highlighted &&
          document
            .querySelector('a[href="' + url + '"]')
            ?.classList.contains("active") &&
          document
            .querySelector('a[href="' + url + '"]')
            ?.classList.remove("active")
      );
      navElement &&
        !navElement.classList.contains("active") &&
        navElement.classList.add("active");
    }
  }, [highlighted, headings]);

  React.useEffect(() => {
    const observer = new IntersectionObserver(
      (entries, observer) => {
        const currentY = window.scrollY;
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            setHighlighted(entry.target.id);
          } else {
            const currentY = window.scrollY;
            const thisIndex = headings.findIndex(
              ({ url }) => url === "#" + entry.target.id
            );
            if (currentY < previousY.current) {
              if (thisIndex > 0 && thisIndex < headings.length) {
                console.log("hjighlight back", currentY, previousY.current);

                const url = headings[thisIndex - 1]?.url;
                setHighlighted(url.substring(1));
              }
            }
          }
          previousY.current = currentY;
        });
      },
      {
        threshold: 0,
        root: document,
        rootMargin: "0% 0% -85% 0%"
      }
    );
    headings.forEach((heading) => {
      const id = heading.url.substring(1);
      const el = document.getElementById(id);
      if (el) {
        observer.observe(el);
      }
    });
    return () => observer.disconnect();
  }, [headings]);
};

const H2 = ({ n }) => (
  <Text
    as="h2"
    className="article-h2"
    sx={{ scrollMarginTop: "1rem" }}
    id={`heading-${n}`}
  >
    Heading {n}
  </Text>
);

const TableOfContents = () => {
  const [headings, setHeadings] = React.useState([]);

  React.useEffect(() => {
    const headings = Array.from(document.querySelectorAll(".article-h2"));
    const headingItems = headings.map((el) => ({
      url: "#" + el.id,
      title: el.innerText
    }));
    setHeadings(headingItems);
  }, []);

  useObserveActiveSection({ headings });

  return (
    <Box
      sx={{
        fontFamily: "body",
        position: "sticky",
        top: 0,
        padding: 3,
        alignSelf: "flex-start",
        border: "1px solid",
        borderColor: "gray.4"
      }}
    >
      <Text as="h4" sx={{ fontSize: 2 }}>
        Table of cotents
      </Text>
      <Box as="ul" sx={{ listStyleType: "none", padding: 0 }}>
        {headings.map(({ title, url }) => (
          <li key={url}>
            <Text
              sx={{
                color: "primary.3",
                "&.active": {
                  color: "primary.2"
                },
                display: "block",
                padding: 1,
                textDecoration: "none"
              }}
              as="a"
              href={url}
            >
              {title}
            </Text>
          </li>
        ))}
      </Box>
    </Box>
  );
};

const Page = () => (
  <Grid gap={4} columns={"1fr 200px"} p={3}>
    <Box sx={{ fontFamily: "body", "p + h2": { marginTop: "3rem" } }}>
      {Array.from(Array(5)).map((_, n) => (
        <div key={n}>
          <H2 n={n + 1} />
          <p>
            The quick, brown fox jumps over a lazy dog. DJs flock by when MTV ax
            quiz prog. Junk MTV quiz graced by fox whelps. Bawds jog, flick
            quartz, vex nymphs. Waltz, bad nymph, for quick jigs vex! Fox nymphs
            grab quick-jived waltz. Brick quiz whangs jumpy veldt fox. Bright
            vixens jump; dozy fowl quack.
          </p>

          <p>
            Quick wafting zephyrs vex bold Jim. Quick zephyrs blow, vexing daft
            Jim. Sex-charged fop blew my junk TV quiz. How quickly daft jumping
            zebras vex. Two driven jocks help fax my big quiz. Quick, Baz, get
            my woven flax jodhpurs! "Now fax quiz Jack! " my brave ghost pled.
          </p>
        </div>
      ))}
    </Box>
    <TableOfContents />
  </Grid>
);

export default Page;

Improvements

  • Add a way to set the highlighted heading from outside of the hook.
    This way, you can ensure a heading shows as highlighted, even if there's not enough space at the bottom of the viewport to hit the intersection.

  • Given a data structure like below, how would you display and make the sub-items (i.e, the h3s) indent?

headings.json
{
  "items": [
    {
      "url": "#basic-markup",
      "title": "Basic markup",
      "items": [
        {
          "url": "#data-structure",
          "title": "Data structure"
        }
      ]
    },
    {
      "url": "#making-it-dynamic",
      "title": "Making it dynamic",
      "items": [
        {
          "url": "#intersectionobserver",
          "title": "IntersectionObserver"
        },
        {
          "url": "#adding-the-active-class",
          "title": "Adding the active class"
        }
      ]
    },
    {
      "url": "#complete-implementation",
      "title": "Complete implementation"
    },
    {
      "url": "#improvements",
      "title": "Improvements"
    }
  ]
}
headings.json
{
  "items": [
    {
      "url": "#basic-markup",
      "title": "Basic markup",
      "items": [
        {
          "url": "#data-structure",
          "title": "Data structure"
        }
      ]
    },
    {
      "url": "#making-it-dynamic",
      "title": "Making it dynamic",
      "items": [
        {
          "url": "#intersectionobserver",
          "title": "IntersectionObserver"
        },
        {
          "url": "#adding-the-active-class",
          "title": "Adding the active class"
        }
      ]
    },
    {
      "url": "#complete-implementation",
      "title": "Complete implementation"
    },
    {
      "url": "#improvements",
      "title": "Improvements"
    }
  ]
}

Read next:

How I converted our UI library from react-docgen to Storybook
Apr 26th, 2022
post

How I converted our UI library from react-docgen to Storybook

renrizzolo/blog

    © 2023 Ren Rizzolo

    @ren_rizgithubprojects