How to Create a Performant "Listening" Table of Contents in React
Building a Table of Contents (TOC) that “listens” to your scroll position is a great way to improve UX, especially for long-form content like Terms and Conditions or Documentation. In this guide, we’ll build a TOC that highlights the active section as you scroll, with a focus on performance.
1. Define Your Data Structure
First, we need an array of sections. We use Object.freeze to ensure this metadata remains constant and doesn’t get re-declared on every render.
JavaScript
const SECTIONS = Object.freeze([ { id: 'umum', title: 'Informasi Umum' }, { id: 'penggunaan-dan-penghentian', title: 'Penggunaan dan penghentian layanan' }, { id: 'pernyataan-dan-persetujuan', title: 'Pernyataan dan Persetujuan' }, { id: 'larangan-dan-tanggung-jawab', title: 'Larangan dan Tanggung Jawab Pengguna' }, { id: 'pembatasan-tanggung-jawab', title: 'Pembatasan Tanggung Jawab' }, { id: 'hubungi-kami', title: 'Hubungi Experience' }]);2. The Implementation Logic
The strategy is simple:
-
Listen to the
scrollevent. -
Check the
offsetTopof each section. -
Compare it against the current scroll position + an offset (to account for your Header/Navbar).
3. The Optimized Code
We will add Debouncing to our scroll listener. Without this, the function runs hundreds of times per second, which can cause lag on mobile devices.
JavaScript
import { useState, useEffect } from 'react';
export default function TermsAndConditionsPage() { const [activeSection, setActiveSection] = useState(''); const HEADER_OFFSET = 150; // Height of your sticky header in pixels
// Helper function to scroll to a section when clicking the TOC const scrollToSection = (id) => { const element = document.getElementById(id); if (element) { const top = element.getBoundingClientRect().top + window.scrollY - HEADER_OFFSET; window.scrollTo({ top, behavior: 'smooth' }); } };
const updateActiveSection = () => { const scrollPosition = window.scrollY;
// 1. Check if we are at the very bottom of the page if (window.innerHeight + scrollPosition >= document.body.offsetHeight - 10) { setActiveSection(SECTIONS[SECTIONS.length - 1].id); return; }
// 2. Iterate backwards to find the current active section for (let i = SECTIONS.length - 1; i >= 0; i--) { const section = document.getElementById(SECTIONS[i].id); if (section && section.offsetTop <= scrollPosition + HEADER_OFFSET + 10) { setActiveSection(SECTIONS[i].id); break; } } };
useEffect(() => { let timeoutId = null;
const debouncedScroll = () => { if (timeoutId) return; timeoutId = setTimeout(() => { updateActiveSection(); timeoutId = null; }, 50); // Run logic only once every 50ms };
window.addEventListener('scroll', debouncedScroll); updateActiveSection(); // Initial check
return () => { window.removeEventListener('scroll', debouncedScroll); if (timeoutId) clearTimeout(timeoutId); }; }, []);
return ( <div style={{ display: 'flex' }}> {/* Sidebar Table of Contents */} <nav style={{ position: 'sticky', top: '150px', height: 'fit-content' }}> {SECTIONS.map((section) => ( <div key={section.id} onClick={() => scrollToSection(section.id)} style={{ cursor: 'pointer', fontWeight: activeSection === section.id ? 'bold' : 'normal', color: activeSection === section.id ? '#3182CE' : 'black', borderLeft: activeSection === section.id ? '3px solid #3182CE' : '3px solid transparent', paddingLeft: '12px', transition: 'all 0.2s ease', marginBottom: '10px' }} > {section.title} </div> ))} </nav>
{/* Main Content */} <main> {SECTIONS.map((section) => ( <section id={section.id} key={section.id} style={{ minHeight: '600px' }}> <h1>{section.title}</h1> <p>Your content goes here...</p> </section> ))} </main> </div> );}Why these fixes matter:
-
Debouncing (50ms): This reduces the CPU load significantly. The user won’t notice a 50ms delay, but their battery and browser performance will thank you.
-
The Bottom Check: Often, the last section isn’t long enough to reach the top of the screen. Without
document.body.offsetHeight, the last item in your TOC might never get highlighted. -
Reverse Loop: By starting from the bottom of the array (
SECTIONS.length - 1), the code finds the most recent section you’ve passed. -
BoundingClientRect for Smooth Scroll: Using this ensures that when you click a link, it lands exactly where you expect, regardless of how many relative elements are on the page.
Summary
Adding a listening indicator is 50% logic and 50% performance. By combining offsetTop with a debounced scroll listener, you get a smooth, professional-feeling navigation that works across all devices.