Vanilla JS
const headings = document.querySelectorAll('h2');
const callback = entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const link = document.querySelector(`nav a[href="#${entry.target.id}"]`);
if (link) {
link.classList.add('active');
}
} else {
const link = document.querySelector(`nav a[href="#${entry.target.id}"]`);
if (link) {
link.classList.remove('active');
}
}
});
};
const observer = new IntersectionObserver(callback, {
rootMargin: '-50% 0px -50% 0px'
});
headings.forEach(heading => {
observer.observe(heading);
});
const nav = document.querySelector('nav ul');
headings.forEach(heading => {
const li = document.createElement('li');
const link = document.createElement('a');
link.href = `#${heading.id}`;
link.textContent = heading.textContent;
li.appendChild(link);
nav.appendChild(li);
});
React
import { useEffect, useState, useRef } from 'react';
function TableOfContents() {
const [headings, setHeadings] = useState([]);
const [activeIndex, setActiveIndex] = useState(0);
const headingsRef = useRef([]);
useEffect(() => {
const h2s = document.querySelectorAll('h2');
setHeadings([...h2s]);
}, []);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
const index = headings.indexOf(entries[0].target);
setActiveIndex(index);
}, {
rootMargin: '-50% 0px -50% 0px',
});
headings.forEach((heading) => {
observer.observe(heading);
});
headingsRef.current = headings;
}, [headings]);
return (
<nav>
<ul>
{headings.map((heading, index) => (
<li key={index}>
<a
href={`#${heading.id}`}
className={activeIndex === index ? 'active' : ''}
>
{heading.textContent}
</a>
</li>
))}
</ul>
</nav>
);
}
Next.js(CSR)
import { useEffect, useState, useRef } from 'react';
function TableOfContents() {
const [headings, setHeadings] = useState([]);
const [activeIndex, setActiveIndex] = useState(0);
const headingsRef = useRef([]);
useEffect(() => {
const h2s = document.querySelectorAll('h2');
setHeadings([...h2s]);
}, []);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
const index = headings.indexOf(entries[0].target);
setActiveIndex(index);
}, {
rootMargin: '-50% 0px -50% 0px',
});
headings.forEach((heading) => {
observer.observe(heading);
});
headingsRef.current = headings;
}, [headings]);
return (
<nav>
<ul>
{headings.map((heading, index) => (
<li key={index}>
<a
href={`#${heading.id}`}
className={activeIndex === index ? 'active' : ''}
>
{heading.textContent}
</a>
</li>
))}
</ul>
</nav>
);
}
export default function MyPage() {
return (
<div>
<TableOfContents />
<h1>Welcome to my page</h1>
<section>
<h2 id="section1">Section 1</h2>
<p>Section 1 content...</p>
</section>
<section>
<h2 id="section2">Section 2</h2>
<p>Section 2 content...</p>
</section>
<section>
<h2 id="section3">Section 3</h2>
<p>Section 3 content...</p>
</section>
</div>
);
}
Next.js(SSR)
import { useState, useLayoutEffect } from 'react';
function TableOfContents() {
const [headings, setHeadings] = useState([]);
const [activeIndex, setActiveIndex] = useState(0);
function handleIntersectionChange(event) {
if (event.isIntersecting) {
const index = headings.indexOf(event.target);
setActiveIndex(index);
}
}
useLayoutEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(handleIntersectionChange);
},
{ rootMargin: '-50% 0px -50% 0px', threshold: 1 }
);
headings.forEach((heading) => observer.observe(heading));
return () => {
headings.forEach((heading) => observer.unobserve(heading));
};
}, [headings]);
return (
<nav>
<ul>
{headings.map((heading, index) => (
<li key={index}>
<a
href={`#${heading.id}`}
className={activeIndex === index ? 'active' : ''}
>
{heading.textContent}
</a>
</li>
))}
</ul>
{headings.map((heading, index) => (
<div key={index} ref={(ref) => setHeadings((prevHeadings) => [...prevHeadings, ref])}>
{React.cloneElement(heading, { ref: undefined })}
</div>
))}
</nav>
);
}
export default function MyPage() {
return (
<div>
<TableOfContents />
<h1>Welcome to my page</h1>
<section>
<h2 id="section1">Section 1</h2>
<p>Section 1 content...</p>
</section>
<section>
<h2 id="section2">Section 2</h2>
<p>Section 2 content...</p>
</section>
<section>
<h2 id="section3">Section 3</h2>
<p>Section 3 content...</p>
</section>
</div>
);
}
Next.js(SSR with react-intersection-observer
)
import { useState } from 'react';
import dynamic from 'next/dynamic';
const IntersectionObserver = dynamic(
() => import('react-intersection-observer'),
{ ssr: false }
);
function TableOfContents() {
const [headings, setHeadings] = useState([]);
const [activeIndex, setActiveIndex] = useState(0);
function handleIntersectionChange(event) {
if (event.isIntersecting) {
const index = headings.indexOf(event.target);
setActiveIndex(index);
}
}
function handleHeadingRef(ref) {
if (ref) {
setHeadings((prevHeadings) => [...prevHeadings, ref]);
}
}
return (
<nav>
<ul>
{headings.map((heading, index) => (
<li key={index}>
<a
href={`#${heading.id}`}
className={activeIndex === index ? 'active' : ''}
>
{heading.textContent}
</a>
</li>
))}
</ul>
{headings.map((heading, index) => (
<IntersectionObserver
key={index}
onChange={handleIntersectionChange}
rootMargin="-50% 0px -50% 0px"
threshold={1}
>
<div ref={handleHeadingRef}>
{React.cloneElement(heading, { ref: undefined })}
</div>
</IntersectionObserver>
))}
</nav>
);
}
export default function MyPage() {
return (
<div>
<TableOfContents />
<h1>Welcome to my page</h1>
<section>
<h2 id="section1">Section 1</h2>
<p>Section 1 content...</p>
</section>
<section>
<h2 id="section2">Section 2</h2>
<p>Section 2 content...</p>
</section>
<section>
<h2 id="section3">Section 3</h2>
<p>Section 3 content...</p>
</section>
</div>
);
}