Create a table of contents with highlighting in React

christianah5

christianah5

Posted on July 30, 2023

Create a table of contents with highlighting in React

A table of contents summarizes the page's content, allowing site visitors to swiftly move to portions of the page by clicking on the desired heading. Tables of contents are commonly used in manuals and blogs. This component will dynamically render a list of page headings and highlight which heading you are currently viewing.

In this article, we'll look at how to make a sticky table of contents that dynamically lists the available headings on a page, emphasizing the active headings. As we read our article, when a heading appears on the screen, it will be highlighted in the TOC, as shown in the animation below:

prerequisite

  • Node.js installed on your system.

  • Basic understanding of react js.

Setting up React

  • Open a terminal(Windows Command Prompt or PowerShell).

  • Create a new project folder: mkdir ReactProjects and enter that directory: cd ReactProjects.

  • Install React using create-react-app, a tool that installs all of the dependencies to build and run a full React.js application:

`npx create-react-app my-app

npm start // to start react
`

Create a Table of Content Component (TOC)

Our TOC component will be placed on the right side of our screen, so let's start by designing it.
first create aTableOfContent.jsfile and a tableOfContent.css file in the src directory. Add the following lines of code to the TableOfContent.js file:

// src/TableOfContent.js
import './tableOfContent.css'

function TableOfContent() {
  return (
    <nav>
      <ul>
        <li>
          <a href='#'>A heading</a>
        </li>
      </ul>
    </nav>
  )
}
export default TableOfContent
Enter fullscreen mode Exit fullscreen mode

From code above we imports a CSS file named tableOfContent.css. It defines a React functional component called TableOfContent that renders a navigation bar <nav> with an unordered list <ul> containing a single list item <li> with a placeholder anchor <a> link to "A heading" (represented by '#'). The component is exported for use in other files.

Next, add the following lines of code in the tableOfContent.css file:

// src/tableOfContent.css
nav {
  width: 220px;
  min-width: 220px;
  padding: 16px;
  align-self: flex-start;
  position: -webkit-sticky;
  position: sticky;
  top: 48px;
  max-height: calc(100vh - 70px);
  overflow: auto;
  margin-top: 150px;
}

nav ul li {
  margin-bottom: 15px;
}

Enter fullscreen mode Exit fullscreen mode

To display this component, navigate to the App.js file and insert the following import:

import TableOfContent from './TableOfContent';
Enter fullscreen mode Exit fullscreen mode

Next, modify the App component to look like the following:

// src/App.js
function App() {
  return (
    <div className="wrapper">
      <Content />
      <TableOfContent />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

With the code above, we'll see a sticky component on the right side of our app.

Find the headings on the page

We can use the querySelectorAll document method to locate all of the headers on our page, which returns a NodeList containing a list of items that match the supplied collection of selectors.

The following example demonstrates how to utilize the querySelectorAll method:

const headings = document.querySelectorAll(h2, h3, h4);
Enter fullscreen mode Exit fullscreen mode

The code above will select all

,

, and

elements in the DOM and store them in the headings variable as a NodeList, which is similar to an array and contains references to the selected elements.

Now, in the TableOfContent.js file, add the following import to find the headings:

import { useEffect, useState } from 'react';
Enter fullscreen mode Exit fullscreen mode

Next, in the component, add the following lines of code before the return statement:

// src/TableOfContent.js
const [headings, setHeadings] = useState([])

useEffect(() => {
  const elements = Array.from(document.querySelectorAll("h2, h3, h4"))
    .map((elem) => ({
      text: elem.innerText,
    }))
  setHeadings(elements)
}, [])
Enter fullscreen mode Exit fullscreen mode

In the given code above, the React functional component TableOfContent uses the useState and useEffect hooks. It initializes a state variable headings as an empty array and sets it using setHeadings within the useEffect hook. The useEffect hook runs once when the component mounts (empty dependency array []). It selects all

,

, and

elements from the DOM, converts them to an array, extracts their inner text, and stores the resulting array of objects with text content in the headings state variable.

Now, to display the headings in the TOC, modify the return statement of the component to look like the following code:

// src/TableOfContent.js
return (
  <nav>
    <ul>
      {headings.map(heading => (
        <li key={heading.text}>
          <a href='#'>{heading.text}</a>
        &lt;/li>
      ))}
    </ul>
  </nav>

Enter fullscreen mode Exit fullscreen mode

The code above returns a table of contents within a navigation () element, using the headings state. It maps through the headings array, creating list items (

  • ) for each heading. Each list item contains an anchor () link with the heading text, represented by heading.text. The key attribute is set to the heading text to provide a unique identifier for each list item.
  • Link and listing heading in hierarchy

    Headings do the important work of showing the structure of digital content. They help all users understand and navigate your digital content more easily, including those with disabilities.

    When we click on a heading in the TOC, we are not taken to the appropriate section. You'll see that they're all in the same line, with no indication of which is the main heading and which is a subheading. Let's get things sorted out.

    In the TableOfContent component, modify the useEffect Hook to look like the following code:

    // src/TableOfContent.js
    useEffect(() => {
      const elements = Array.from(document.querySelectorAll("h2, h3, h4"))
        .map((elem) => ({
          id: elem.id,
          text: elem.innerText,
          level: Number(elem.nodeName.charAt(1))
        }))
      setHeadings(elements)
    }, [])
    
    Enter fullscreen mode Exit fullscreen mode

    In addition to the text from the headings, we are adding an ID and a level property to the state. We'll provide the ID to the TOC text's anchor tag so that when we click on it, we'll be directed to the appropriate portion of the page. The level property will then be used to establish a hierarchy in the TOC.

    Modify the ul element in the TableOfContent component's return statement to look like this:

    // src/TableOfContent.js
    <ul>
      {headings.map(heading => (
        <li
          key={heading.id}
          className={getClassName(heading.level)}
          >
          <a
            href={`#${heading.id}`}
            onClick={(e) => {
              e.preventDefault()
              document.querySelector(`#${heading.id}`).scrollIntoView({
                behavior: "smooth"
              })}}
            >
            {heading.text}
          </a>
        </li>
      ))}
    </ul>
    
    Enter fullscreen mode Exit fullscreen mode

    The code above returns an unordered list <ul> representing a dynamic table of contents. It maps through the headings array, creating list items <li> for each heading. Each list item contains an anchor <a> link with the heading text, which, when clicked, smoothly scrolls to the corresponding element with the specified heading.id in the document.

    The key attribute is set to heading.id to provide a unique identifier for each list item. The className attribute is set using the getClassName function, which assigns a class based on the** heading.level** value.

    Next, to create the getClassName function, add the following code outside the TableOfContent component:

    // src/TableOfContent.js
    const getClassName = (level) => {
      switch (level) {
        case 2:
          return 'head2'
        case 3:
          return 'head3'
        case 4:
          return 'head4'
        default:
          return null
      }
    }
    
    Enter fullscreen mode Exit fullscreen mode

    The getClassName function takes a level parameter and returns a corresponding class name based on the level value. If level is 2, it returns 'head2'; if 3, it returns 'head3'; if 4, it returns 'head4'; otherwise, it returns null.

    Now, add the following lines of code in the in tableOfContent.css file:

    // src/tableOfContent.css
    .head3{
      margin-left: 10px;
      list-style-type: circle;
    }
    .head4{
      margin-left: 20px;
      list-style-type: square;
    }
    
    Enter fullscreen mode Exit fullscreen mode

    Observing active headings with the Intersection Observer API

    API to detect the visibility allows us to monitor a target element and execute a function when it reaches a predefined point.

    Using the Intersection Observer API, we'll write a custom Hook that returns the active header's ID. The ID returned will then be used to highlight the matching text in our TOC.

    To do so, create a hook.js file in the src directory and add the following lines of code:

    // src/hooks.js
    import { useEffect, useState, useRef } from 'react';
    
    export function useHeadsObserver() {
      const observer = useRef()
      const [activeId, setActiveId] = useState('')
    
      useEffect(() => {
        const handleObsever = (entries) => {}
    
        observer.current = new IntersectionObserver(handleObsever, {
          rootMargin: "-20% 0% -35% 0px"}
        )
    
        return () => observer.current?.disconnect()
      }, [])
    
      return {activeId}
    }
    
    Enter fullscreen mode Exit fullscreen mode

    ChatGPT
    The code above defines a custom React hook useHeadsObserver, which uses IntersectionObserver to observe elements on the page. It initializes observer and activeId as a ref and state variable, respectively, using useRef and useState.

    The useEffect hook sets up the IntersectionObserver with handleObserver as the callback function. The observer is configured to have a rootMargin of "-20% 0% -35% 0px", which adjusts the observation area. The returned function disconnects the observer when the component unmounts.

    Finally, the hook returns an object with the activeId, which represents the ID of the currently intersecting element (the element currently in the viewport or within the specified rootMargin area).

    Let's specify the heads we wish to monitor by supplying them to the Intersection Observer's observe method. We'll also change the handleObsever callback method to save the intersected header's ID in the state.

    To do so, modify the useEffect Hook to look like the code below:

    // src/hooks.js
    useEffect(() => {
      const handleObsever = (entries) => {
        entries.forEach((entry) => {
          if (entry?.isIntersecting) {
            setActiveId(entry.target.id)
          }
        })
      }
    
      observer.current = new IntersectionObserver(handleObsever, {
        rootMargin: "-20% 0% -35% 0px"}
      )
    
      const elements = document.querySelectorAll("h2, h3", "h4")
      elements.forEach((elem) => observer.current.observe(elem))
      return () => observer.current?.disconnect()
    }, [])
    
    
    Enter fullscreen mode Exit fullscreen mode

    looking at the code above, useEffect hook sets up an IntersectionObserver to observe specified elements on the page (h2, h3, and h4). When an observed element becomes intersecting with the viewport or within the specified rootMargin, its ID is set as the activeId state variable. The observer is disconnected when the component unmounts.

    In the TableOfContent.js file, import the created Hook with the following code:

    // src/TableOfContent.js
    import { useHeadsObserver } from './hooks'
    
    Enter fullscreen mode Exit fullscreen mode

    Now, call the Hook after the headings state in the TableOfContent component:

    // src/TableOfContent.js
    const {activeId} = useHeadsObserver()
    
    Enter fullscreen mode Exit fullscreen mode

    When a heading element crosses with the code above, it will be available with activeId.

    Highlighting the active heading

    To highlight the active headings in our TOC, modify the anchor tag of the li element in the returned statement of the TableOfContent component by adding the following style attribute:

    style={{
      fontWeight: activeId === heading.id ? "bold" : "normal" 
    }}
    
    Enter fullscreen mode Exit fullscreen mode

    Now, our anchor tag will look like this:

    // src/TableOfContent.js
    <a
      href={`#${heading.id}`} 
      onClick={(e) => {
        e.preventDefault()
        document.querySelector(`#${heading.id}`).scrollIntoView({
          behavior: "smooth"
        })}}
        style={{
          fontWeight: activeId === heading.id ? "bold" : "normal" 
        }}
      >
      {heading.text}
    </a>
    
    Enter fullscreen mode Exit fullscreen mode

    from the code above we creates an anchor <a>element for each heading, where heading.id is used in the href attribute. When clicked, it smoothly scrolls to the corresponding element. The style attribute sets the fontWeight to "bold" if activeId matches heading.id, otherwise, it's set to "normal". The heading.text is displayed as the link text.
    By following these steps, you can create a functional and interactive table of contents in your React application. The table of contents will automatically update based on the content on the page and highlight the active section as the user scrolls. This navigation feature provides an intuitive way for users to navigate lengthy documents or articles with ease.

    Wrapping Up

    In this tutorial, We learned how to make a table of contents with item highlighting to signify each active header, assisting people in navigating your site and enhancing overall UX.

    💖 💪 🙅 🚩
    christianah5
    christianah5

    Posted on July 30, 2023

    Join Our Newsletter. No Spam, Only the good stuff.

    Sign up to receive the latest update from our blog.

    Related