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 scroll event.
-
Check the offsetTop of 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;
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;
if (window.innerHeight + scrollPosition >= document.body.offsetHeight - 10) {
setActiveSection(SECTIONS[SECTIONS.length - 1].id);
return;
}
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); };
window.addEventListener('scroll', debouncedScroll);
updateActiveSection();
return () => {
window.removeEventListener('scroll', debouncedScroll);
if (timeoutId) clearTimeout(timeoutId);
};
}, []);
return (
<div style={{ display: 'flex' }}>
{}
<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>
{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.