civil-and-structural-engineering
Using Javascript to Create a Dynamic Table of Contents for Long Articles
Table of Contents
Why a Dynamic Table of Contents Matters for Long‑Form Content
Long articles, tutorials, and documentation pages can overwhelm readers if navigation is limited to manual scrolling. A dynamic table of contents (TOC) solves this by providing a clickable outline that automatically highlights the section the reader is viewing. This improves usability, reduces bounce rates, and makes your content more accessible to users who want to quickly jump between topics. Unlike a static TOC written by hand, a JavaScript‑generated TOC updates itself when new sections are added, keeps links in sync with heading IDs, and responds to scroll behavior in real time.
For example, a technical guide with 30 sections becomes much easier to digest when readers see a sidebar menu that tracks their progress. The same principle applies to single‑page applications, API documentation, or even blog posts with multiple sub‑themes. By implementing a dynamic TOC, you give readers control over their reading experience while reducing the cognitive load of searching for relevant parts.
Core Concepts Behind a Dynamic Table of Contents
To build a dynamic TOC, you need to understand three foundational pieces:
- Semantic HTML structure – each section heading must have a unique
idattribute so JavaScript can target it. - DOM traversal and manipulation – your script scans headings, creates a nested list of links, and appends that list to a container element.
- Scroll event handling – an efficient listener checks which heading is currently visible and adds an
activeclass to the corresponding TOC link.
These pieces work together to produce a TOC that feels native to the page, requires minimal server‑side logic, and works across modern browsers.
Step 1: Preparing Your HTML Structure
Before any JavaScript runs, you need two things in your HTML:
Assign Unique IDs to Headings
Every heading that should appear in the TOC (typically <h2>, <h3>, or <h4>) must have a unique id. This is essential because the TOC links use fragment identifiers (e.g., #getting‑started) to scroll to the correct position. Here’s an example:
<h2 id="introduction">Introduction</h2>
<p>...</p>
<h2 id="setup">Setting Up the Environment</h2>
<p>...</p>
<h3 id="installing-dependencies">Installing Dependencies</h3>
<p>...</p>
<h2 id="implementation">Implementation</h2>
<p>...</p>
If you cannot modify the HTML directly, you can generate IDs from heading text using JavaScript (e.g., slugify function), but it’s cleaner to add them manually or with a static site generator.
Create a Container for the TOC
Place an empty element (typically a <nav> or a <div>) where you want the TOC to appear. For example:
<nav id="table-of-contents" aria-label="Table of Contents"></nav>
The aria-label improves accessibility by giving screen readers a descriptive name for the navigation region. Later you’ll populate this container with the generated list.
Step 2: Generating the TOC with JavaScript
Now we write the JavaScript that scans the headings and builds the list. The following snippet creates a flat list of <h2> headings. For a more advanced TOC that includes subheadings, you would need nested lists, which we’ll cover later.
Basic Flat TOC Example
const tocContainer = document.getElementById('table-of-contents');
const headings = document.querySelectorAll('h2');
// Bail early if there's no container or no headings
if (!tocContainer || headings.length === 0) return;
const ul = document.createElement('ul');
ul.setAttribute('role', 'list'); // accessibility enhancement
headings.forEach((heading, index) => {
// Ensure the heading has an id; if not, generate one
if (!heading.id) {
heading.id = 'section-' + index;
}
const li = document.createElement('li');
const a = document.createElement('a');
a.textContent = heading.textContent;
a.href = '#' + heading.id;
a.setAttribute('data-section', heading.id); // useful for active detection
li.appendChild(a);
ul.appendChild(li);
});
tocContainer.appendChild(ul);
Key points:
- The
querySelectorAllmethod returns a staticNodeList; it works for pages where headings don’t change dynamically. - If a heading lacks an
id, we auto‑generate one using the index. This prevents broken links. - We add a
data-sectionattribute to each link for easier selection later.
Handling Nested Headings (H2, H3, H4)
A more useful TOC reflects the document’s hierarchy. To create nested lists, track the current <h2> and insert <ul> for its children. Here’s a simplified approach using a stack:
const tocContainer = document.getElementById('table-of-contents');
const headings = document.querySelectorAll('h2, h3, h4');
if (!tocContainer || headings.length === 0) return;
const root = document.createElement('ul');
const stack = [{ element: root, level: 2 }]; // level refers to heading level
headings.forEach((heading) => {
const level = parseInt(heading.tagName.substring(1), 10); // 'H2' -> 2
if (!heading.id) heading.id = 'section-' + Math.random().toString(36).substr(2, 9);
const li = document.createElement('li');
const a = document.createElement('a');
a.textContent = heading.textContent;
a.href = '#' + heading.id;
li.appendChild(a);
// Pop stack until we reach the parent level
while (stack.length > 0 && stack[stack.length - 1].level >= level) {
stack.pop();
}
const parent = stack[stack.length - 1].element;
parent.appendChild(li);
// If next heading is lower, we need a nested list
const nextLevel = headings.item(Array.from(headings).indexOf(heading) + 1);
if (nextLevel && parseInt(nextLevel.tagName.substring(1), 10) > level) {
const nestedUl = document.createElement('ul');
li.appendChild(nestedUl);
stack.push({ element: nestedUl, level: level });
}
});
tocContainer.appendChild(root);
This algorithm ensures that each subheading appears indented under its parent. For production, you may want to refine the logic to avoid deep stacks and handle edge cases (e.g., missing heading levels).
Step 3: Highlighting the Active Section on Scroll
The highlight mechanic lets readers know which part of the article they’re currently reading. The idea is to loop through all headings, find the one that is closest to the top of the viewport (with some offset), and apply an active class to the corresponding TOC link.
Efficient Scroll Listener
const tocLinks = document.querySelectorAll('#table-of-contents a');
const sections = Array.from(headings).map(h => ({
id: h.id,
top: h.offsetTop
}));
function updateActiveLink() {
const scrollY = window.pageYOffset || document.documentElement.scrollTop;
let currentId = '';
// Iterate backwards for better performance
for (let i = sections.length - 1; i >= 0; i--) {
if (scrollY >= sections[i].top - 150) {
currentId = sections[i].id;
break;
}
}
tocLinks.forEach(link => {
link.classList.remove('active');
if (link.getAttribute('href') === '#' + currentId) {
link.classList.add('active');
}
});
}
// Throttle scroll events for performance
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
window.requestAnimationFrame(() => {
updateActiveLink();
ticking = false;
});
ticking = true;
}
});
Optimizations:
- Use
requestAnimationFrameto limit updates to the browser’s paint cycle. This avoids lag on busy pages. - The offset of 150 pixels ensures the section is “active” a little before it reaches the very top, which feels more natural.
- Iterating backwards from the last heading is more efficient because the active section is likely near the bottom of the visible area.
Step 4: Adding Smooth Scrolling and Accessibility
Smooth scrolling makes jumping between sections pleasant. You can achieve this with CSS, but also via JavaScript for finer control.
// Add click handler on the TOC container to use smooth scrolling
tocContainer.addEventListener('click', (e) => {
const link = e.target.closest('a');
if (link && link.getAttribute('href').startsWith('#')) {
e.preventDefault();
const targetId = link.getAttribute('href').substring(1);
const target = document.getElementById(targetId);
if (target) {
target.scrollIntoView({ behavior: 'smooth' });
// Update the URL hash without causing a scroll jump
history.pushState(null, '', '#' + targetId);
}
}
});
Accessibility improvements:
- Ensure the TOC
<nav>has anaria-label(e.g., “Table of Contents”). - Add
aria-current="location"to the active link:link.setAttribute('aria-current', 'location')instead ofclassList.add('active'). This helps screen readers announce the current section. - Use
role="list"androle="listitem"if the default<ul>/<li>semantics are overridden by styling.
Step 5: Styling the Dynamic TOC
While styling is not part of the JavaScript logic, a well‑styled TOC reinforces usability. Below is a minimal CSS example that adds a sticky positioning for sidebar usage:
#table-of-contents {
position: sticky;
top: 2rem;
max-height: calc(100vh - 4rem);
overflow-y: auto;
border-left: 2px solid #ccc;
padding-left: 1rem;
font-size: 0.9rem;
}
#table-of-contents ul {
list-style: none;
padding: 0;
}
#table-of-contents li {
margin-bottom: 0.25rem;
}
#table-of-contents a {
color: #333;
text-decoration: none;
}
#table-of-contents a.active {
font-weight: bold;
color: #007bff;
}
#table-of-contents a[aria-current="location"] {
border-left: 2px solid #007bff;
margin-left: -1rem;
padding-left: calc(1rem - 2px);
}
For a responsive design, consider hiding the TOC on small screens and adding a toggle button, or collapsing it into a select‑dropdown menu.
Advanced Enhancements
1. Debouncing Resize Events
If the viewport height changes (e.g., on mobile orientation change), the offsetTop values of headings may shift. Recalculate the sections array on a debounced resize:
let sections = [];
function recalcSections() {
sections = Array.from(headings).map(h => ({
id: h.id,
top: h.offsetTop
}));
}
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(recalcSections, 250);
});
2. Intersection Observer for Scroll‑Based Highlighting
An alternative to scroll listeners is the IntersectionObserver API. It’s more performant and easier to manage. Example:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = entry.target.id;
tocLinks.forEach(link => {
link.classList.remove('active');
if (link.getAttribute('href') === '#' + id) {
link.classList.add('active');
}
});
}
});
}, { rootMargin: '-80px 0px -70% 0px' });
headings.forEach(h => observer.observe(h));
This fires only when a heading enters or leaves a computed zone, reducing overhead. The rootMargin defines when a section is considered “active”.
3. Lazy Loading or Dynamic Content
If your article loads sections dynamically (e.g., via AJAX), you must regenerate the TOC after new content appears. One way is to use a MutationObserver on the article container and call the TOC generation function again. However, be careful not to duplicate entries.
Performance Considerations
- Avoid heavy DOM queries inside scroll handlers. Cache all selectors once at initialization.
- Use passive event listeners for scroll:
window.addEventListener('scroll', handler, { passive: true }). This improves scrolling performance, especially on mobile. - Don’t throttle with
setInterval–requestAnimationFrameis more efficient because it synchronises with the browser’s render loop. - Minify and defer the script so it does not block page load. Place the script right before
</body>or use thedeferattribute.
Integrating with a Static Site Generator (SSG) or CMS
If you use a static site generator, you can pre‑render the TOC using built‑in features (e.g., Eleventy’s collections, Hugo’s .TableOfContents). However, the dynamic scroll‑highlighting still requires client‑side JavaScript. The advantage of a server‑side TOC is that it’s available immediately, even before JavaScript runs, aiding SEO and accessibility.
For a CMS like WordPress or Directus, you can use the same JavaScript approach while storing heading IDs in the content. Directus, for example, supports custom interfaces that generate IDs automatically. You could create a hook that runs on content save to add IDs to headings, then rely on the front‑end JavaScript to build the TOC.
External resources for deeper understanding:
- MDN – Intersection Observer API
- CSS‑Tricks – Complete Guide to Table of Contents
- WAI – Fly‑out Menus (Accessibility)
Testing and Debugging
- Verify that every heading has a unique
id. Duplicate IDs cause the browser to scroll to the first match only. - Check the TOC in both light and dark themes to ensure link contrast meets WCAG AA standards.
- Test with keyboard navigation: pressing Tab should move between TOC links, and Enter should scroll to the section.
- Use the browser’s DevTools Performance tab to ensure no jank during scroll.
- If the article contains images or iframes, the
offsetTopmay change after those elements load. Call a recalculation function onwindow.loador after all images are loaded (e.g.,document.fonts.ready).
Potential Pitfalls and How to Avoid Them
- Broken links when headings lack IDs. Always check for an
idand generate one if missing (use a slugify utility). - TOC flickering during scroll – caused by too many reflows. Use
requestAnimationFrameand cacheoffsetTopvalues. - Overlapping sections – the active highlight might switch too early or too late. Adjust the
rootMarginin IntersectionObserver or the offset in scroll handler. - Nested TOC indentation issues – test with multiple levels (H2 → H3 → H4) and ensure the list renders correctly. The stack‑based approach above works but can be extended to handle gaps (e.g., H2 directly followed by H4).
- Performance on long pages – if you have hundreds of headings, consider limiting the TOC to H2 and H3 only, or implement virtual scrolling for the sidebar.
Conclusion
Building a dynamic table of contents with JavaScript transforms a long, linear article into an interactive, scannable resource. By assigning IDs to headings, generating a nested list of links, and highlighting the current section based on scroll position, you give readers a clear roadmap. The code examples in this article provide a solid foundation, but you can easily extend them—add smooth scrolling, use IntersectionObserver for better performance, or integrate with your existing build process. The result is a more professional reading experience that respects user time and attention, especially on content‑heavy sites like documentation portals, tutorials, or long‑form journalism.
Implement the approach that best fits your stack: pure JavaScript for simple sites, or a hybrid with SSG for initial TOC structure plus client‑side highlighting. Regardless of the method, a dynamic TOC is a small investment that yields significant usability gains.