Search code examples
javascriptreactjsnext.jsintersection-observer

How to highlight TOC using IntersectionObserver in NEXT JS


I'm building my app in NextJS and I'd like to highlight entry in my TOC when its content is visible. I found this solution: https://css-tricks.com/table-of-contents-with-intersectionobserver/ but I don't know how to rewrite it for react. Are there any other React-ready solutions?


Solution

  • To use IntersectionObserver in nextJS (React), firstly you need to create a client component (so insert the "use client" line at the very top of you file). Then, you have to import useEffect, because the IntersectionObserver API can be used only once the window object is available, so in the dependecies array you have to insert window. Then, create the IntersectionObserver object, in this way

    useEffect(() => {
            const observer = new IntersectionObserver((entries) => { })
        }, [window]);
    

    Now, i think you have a container, and inside it, at right, the content, and at left a position fixed div with an ul and all the li tags inside it to represent your TOC. You have to keep track of all of your text paragraphs, and observe them. You can use an array of useRef, or other data structure if you want to represent also subTopics (like an hashmap). Because this is a minimal example i used a simple array with two entries, like this

    const allToc = [useRef(null), useRef(null)];
    const allElems = [useRef(null), useRef(null)];
    

    Then you have to observe them, so in your useEffect hook insert the following code

    allElems.forEach((ref) => {
                observer.observe(ref.current);
            })
    

    I suppose also that every topix has an id that corresponds to the position of the topic in the array, so for example

    <h1 ref={allElems[0]} id="0">Topic1</h1>
    <div style={{height: "1000px"}}></div>
    <p ref={allElems[1]} id="1">Topic2</p>
    

    The last thing you have to do is to add the logic to the observer, so in your IntersectionObserver object add this code

     entries.forEach(entry => {
                    if(entry.isIntersecting) {
                        // get index of entry
                        const index = parseInt(entry.target.id);
                        allToc[index].current.style.color = "red";
                        // all others should be black
                        allToc.forEach((ref, i) => {
                            if(i !== index) {
                                ref.current.style.color = "black";
                            }
                        })
                    }
                })
    

    In the end, your code should look like this

    const allToc = [useRef(null), useRef(null)]
    const allElems = [useRef(null), useRef(null)]
    useEffect(() => {
            const observer = new IntersectionObserver((entries) => {
                entries.forEach(entry => {
                    if(entry.isIntersecting) {
                        // get index of entry
                        const index = parseInt(entry.target.id);
                        allToc[index].current.style.color = "red";
                        // all others should be black
                        allToc.forEach((ref, i) => {
                            if(i !== index) {
                                ref.current.style.color = "black";
                            }
                        })
                    }
                })})
    
            allElems.forEach((ref) => {
                observer.observe(ref.current);
            })
        }, [allElems, allToc]);
    

    And your jsx something like

    <div id="container" style={{width: "80%"}}>
        <div className="text">
            <h1 ref={allElems[0]} id="0">Test</h1>
            <div style={{height: "1000px"}}></div>
            <p ref={allElems[1]} id="1">Test</p>
        </div>
    </div>
    <div id="toc" style={{position: "fixed", left: "80%"}}>
        <ul>
            {tocEntries.map((entry, index) =>
                <li key={index} ref={allToc[index]} id={index.toString()}>
                    {entry}
                </li>
            )
            }
        </ul>
    </div>