Auto Table of Contents (TOC) with Scroll-Spy & Reading Progress Bar Source Code
Create a project folder with three files: index.html, style.css, and script.js. (A ready-to-use WordPress functions.php snippet is also included in Part 2 so you can deploy this on any WordPress site without plugins.)
1. HTML (index.html)
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auto Table of Contents with Scroll-Spy & Reading Progress</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Top Reading Progress Bar -->
<div class="progress-container" aria-hidden="true">
<div id="readingProgressBar" class="progress-bar"></div>
</div>
<!-- Site Header -->
<header class="navbar">
<div class="logo">NitroX Web</div>
<div class="reading-meta">
<span id="readingTimeBadge" class="badge">Calculating read time...</span>
<span id="scrollPercentBadge" class="percent-pill">0%</span>
</div>
</header>
<!-- Main Layout Grid: Article + Sticky Sidebar TOC -->
<main class="layout-container">
<!-- Sticky Table of Contents Sidebar -->
<aside class="toc-sidebar">
<div class="toc-card" id="tocCard">
<div class="toc-header">
<div class="toc-title-wrap">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="toc-icon">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
<h2>On This Page</h2>
</div>
<button id="tocToggleBtn" class="toc-toggle-btn" type="button" aria-expanded="true" aria-controls="tocListWrap">
Hide
</button>
</div>
<nav id="tocListWrap" class="toc-nav" aria-label="Table of Contents">
<ul id="tocList" class="toc-list">
<!-- Dynamically populated by JavaScript -->
</ul>
</nav>
</div>
</aside>
<!-- Article Content Area (Scanned Automatically for H2 & H3) -->
<article id="articleContent" class="article-card">
<header class="article-header">
<span class="category-tag">Frontend Engineering</span>
<h1>How Modern Browsers Render Websites: From DOM to Pixels</h1>
<p class="article-lead">Scroll down this article to watch the automatic Table of Contents highlight active sections in real time using the native IntersectionObserver API.</p>
</header>
<h2>1. Constructing the DOM Tree</h2>
<p>When a browser receives raw HTML bytes over the network, its first job is parsing those characters into meaningful tokens. Each start tag, end tag, and text string is converted into nodes that form the Document Object Model (DOM) tree.</p>
<p>Because HTML parsing happens incrementally from top to bottom, placing render-blocking scripts inside the document head pauses DOM construction until the script downloads and executes.</p>
<h3>HTML Tokenization & Lexical Analysis</h3>
<p>During tokenization, the parser follows strict state-machine rules defined by the HTML5 specification, automatically fixing unclosed tags or misnested elements so the visual layout never crashes.</p>
<h3>Handling Render-Blocking Scripts</h3>
<p>Adding the <code>defer</code> or <code>async</code> attribute to external script tags tells the browser to continue building the DOM tree in parallel while fetching JavaScript files in the background.</p>
<h2>2. Building the CSSOM (CSS Object Model)</h2>
<p>While the HTML parser constructs the DOM, the browser simultaneously parses external stylesheets, inline style blocks, and user-agent defaults into the CSS Object Model (CSSOM).</p>
<p>Unlike HTML, CSS cannot be rendered partially—because a rule at the very bottom of a stylesheet can override a rule at the top, the browser waits until the entire CSSOM is complete before painting pixels.</p>
<h3>Specificity & Cascade Resolution</h3>
<p>Every selector is assigned a specificity weight based on IDs, classes, and element names. Computed styles are resolved by inheriting parent properties and applying custom CSS variables.</p>
<h2>3. Combining Into the Render Tree</h2>
<p>Once both the DOM and CSSOM exist in memory, the browser merges them into the Render Tree. The Render Tree only includes nodes that will actually appear on screen.</p>
<p>Elements hidden via <code>display: none</code> (along with non-visual tags like <code><head></code>, <code><script></code>, and <code><meta></code>) are completely omitted from the Render Tree, whereas elements styled with <code>visibility: hidden</code> or <code>opacity: 0</code> remain in the tree because they still occupy physical layout space.</p>
<h2>4. Layout (Reflow) & Box Geometry</h2>
<p>With the Render Tree ready, the browser runs the Layout stage (often called Reflow). During Layout, the engine calculates the exact X/Y coordinates and pixel dimensions of every box relative to the viewport.</p>
<h3>Avoiding Expensive Layout Thrashing</h3>
<p>Repeatedly reading geometric properties like <code>offsetHeight</code> or <code>getBoundingClientRect()</code> immediately after modifying DOM styles forces the browser to recalculate layout synchronously, causing frame drops during scrolling.</p>
<h2>5. Painting & GPU Compositing</h2>
<p>Finally, the browser converts layout boxes into actual raster pixels on the screen during the Paint and Composite phases. Complex visual properties like 3D transforms, opacity transitions, and canvas animations are promoted to dedicated GPU layers so they can animate smoothly at 60+ frames per second without re-triggering layout or paint.</p>
</article>
</main>
<!-- Floating Back-to-Top Button -->
<button id="backToTopBtn" class="back-to-top" type="button" aria-label="Scroll back to top" hidden>
↑ Top
</button>
<script src="script.js"></script>
</body>
</html>
2. CSS (style.css)
CSS
/* ==========================================================================
NitroX Web Variables & Reset
========================================================================== */
:root {
--bg-dark: #070a12;
--bg-card: #101626;
--bg-subtle: #0b0f1b;
--border-color: #1e293b;
--text-main: #f8fafc;
--text-body: #cbd5e1;
--text-muted: #94a3b8;
--accent-mint: #10b981;
--accent-blue: #3b82f6;
--radius-lg: 16px;
--radius-md: 10px;
--header-height: 68px;
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--shadow-lg: 0 20px 35px -10px rgba(0, 0, 0, 0.6);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
scroll-padding-top: calc(var(--header-height) + 28px);
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg-dark);
color: var(--text-main);
line-height: 1.75;
}
/* ==========================================================================
Top Reading Progress Bar
========================================================================== */
.progress-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: rgba(30, 41, 59, 0.6);
z-index: 1001;
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--accent-mint), var(--accent-blue));
transition: width 0.08s linear;
}
/* ==========================================================================
Sticky Header Navbar
========================================================================== */
.navbar {
position: sticky;
top: 0;
z-index: 1000;
height: var(--header-height);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 2rem;
background: rgba(16, 22, 38, 0.88);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-color);
}
.logo {
font-weight: 800;
font-size: 1.25rem;
letter-spacing: -0.02em;
color: var(--accent-mint);
}
.reading-meta {
display: flex;
align-items: center;
gap: 0.65rem;
}
.badge {
background: rgba(16, 185, 129, 0.12);
color: var(--accent-mint);
font-size: 0.76rem;
font-weight: 700;
padding: 0.3rem 0.8rem;
border-radius: 999px;
}
.percent-pill {
background-color: var(--bg-subtle);
border: 1px solid var(--border-color);
color: var(--text-muted);
font-family: monospace;
font-size: 0.78rem;
font-weight: 700;
padding: 0.25rem 0.6rem;
border-radius: 6px;
min-width: 48px;
text-align: center;
}
/* ==========================================================================
Main Two-Column Grid Layout
========================================================================== */
.layout-container {
max-width: 1180px;
margin: 2.5rem auto 5rem;
padding: 0 1.5rem;
display: grid;
grid-template-columns: 300px 1fr;
gap: 2.5rem;
align-items: start;
}
/* ==========================================================================
Sticky Table of Contents Card
========================================================================== */
.toc-sidebar {
position: sticky;
top: calc(var(--header-height) + 24px);
}
.toc-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.35rem;
box-shadow: var(--shadow-lg);
}
.toc-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.85rem;
margin-bottom: 0.85rem;
border-bottom: 1px solid var(--border-color);
}
.toc-title-wrap {
display: flex;
align-items: center;
gap: 0.5rem;
}
.toc-icon {
color: var(--accent-mint);
}
.toc-header h2 {
font-size: 0.95rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.toc-toggle-btn {
background: var(--bg-subtle);
color: var(--text-muted);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 0.2rem 0.6rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: color var(--transition), border-color var(--transition);
}
.toc-toggle-btn:hover {
color: var(--text-main);
border-color: var(--accent-mint);
}
.toc-nav.collapsed {
display: none;
}
.toc-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: calc(100vh - 220px);
overflow-y: auto;
padding-right: 4px;
}
.toc-link {
display: block;
text-decoration: none;
font-size: 0.86rem;
font-weight: 500;
color: var(--text-muted);
padding: 0.42rem 0.75rem;
border-left: 2px solid var(--border-color);
border-radius: 0 6px 6px 0;
line-height: 1.4;
transition: all var(--transition);
}
/* Sub-heading (H3) Indentation */
.toc-item-h3 .toc-link {
padding-left: 1.5rem;
font-size: 0.81rem;
}
.toc-link:hover {
color: var(--text-main);
background-color: rgba(255, 255, 255, 0.03);
}
/* Active Scroll-Spy Highlight */
.toc-link.active {
color: var(--accent-mint);
font-weight: 700;
border-left-color: var(--accent-mint);
background: rgba(16, 185, 129, 0.1);
}
/* ==========================================================================
Article Content Area
========================================================================== */
.article-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 3rem;
box-shadow: var(--shadow-lg);
}
.article-header {
margin-bottom: 2.5rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border-color);
}
.category-tag {
display: inline-block;
color: var(--accent-blue);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 0.6rem;
}
.article-header h1 {
font-size: 2.25rem;
font-weight: 850;
line-height: 1.25;
letter-spacing: -0.03em;
margin-bottom: 1rem;
}
.article-lead {
font-size: 1.1rem;
color: var(--text-muted);
}
.article-card h2 {
font-size: 1.55rem;
font-weight: 800;
margin: 2.5rem 0 0.85rem;
letter-spacing: -0.02em;
color: var(--text-main);
}
.article-card h3 {
font-size: 1.2rem;
font-weight: 700;
margin: 1.75rem 0 0.6rem;
color: #e2e8f0;
}
.article-card p {
color: var(--text-body);
margin-bottom: 1.25rem;
font-size: 1rem;
}
.article-card code {
background-color: var(--bg-subtle);
border: 1px solid var(--border-color);
color: var(--accent-mint);
padding: 0.15rem 0.45rem;
border-radius: 5px;
font-size: 0.88em;
}
/* Automatic Heading Anchor Permalink Icon on Hover */
.heading-anchor {
color: var(--text-muted);
text-decoration: none;
margin-left: 0.45rem;
opacity: 0;
font-weight: 400;
transition: opacity var(--transition), color var(--transition);
}
.article-card h2:hover .heading-anchor,
.article-card h3:hover .heading-anchor {
opacity: 1;
}
.heading-anchor:hover {
color: var(--accent-mint);
}
/* ==========================================================================
Floating Back-To-Top Button
========================================================================== */
.back-to-top {
position: fixed;
bottom: 2rem;
right: 2rem;
background-color: var(--accent-mint);
color: #05080e;
border: none;
border-radius: 999px;
padding: 0.65rem 1.15rem;
font-size: 0.85rem;
font-weight: 800;
cursor: pointer;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.4);
transition: transform var(--transition), background-color var(--transition);
z-index: 900;
}
.back-to-top:hover {
transform: translateY(-2px);
background-color: #059669;
}
/* ==========================================================================
Responsive Design (< 900px)
========================================================================== */
@media (max-width: 900px) {
.layout-container {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.toc-sidebar {
position: static;
}
.article-card {
padding: 1.75rem;
}
.article-header h1 {
font-size: 1.75rem;
}
}
3. JavaScript (script.js)
JavaScript
document.addEventListener('DOMContentLoaded', () => {
const article = document.getElementById('articleContent');
const tocList = document.getElementById('tocList');
const tocCard = document.getElementById('tocCard');
const tocToggleBtn = document.getElementById('tocToggleBtn');
const tocListWrap = document.getElementById('tocListWrap');
const readingTimeBadge = document.getElementById('readingTimeBadge');
const readingProgressBar = document.getElementById('readingProgressBar');
const scrollPercentBadge = document.getElementById('scrollPercentBadge');
const backToTopBtn = document.getElementById('backToTopBtn');
if (!article || !tocList) return;
// ==========================================================================
// 1. Calculate & Display Estimated Reading Time (200 WPM Standard)
// ==========================================================================
const calculateReadingTime = () => {
const rawText = article.innerText || article.textContent;
const wordCount = rawText.trim().split(/\s+/).length;
const minutes = Math.max(1, Math.ceil(wordCount / 200));
if (readingTimeBadge) {
readingTimeBadge.textContent = `${minutes} min read (${wordCount} words)`;
}
};
calculateReadingTime();
// ==========================================================================
// 2. Scan Headings (H2 & H3), Generate Unique IDs & Build TOC
// ==========================================================================
const headings = Array.from(article.querySelectorAll('h2, h3'));
if (headings.length === 0) {
if (tocCard) tocCard.hidden = true;
return;
}
const usedSlugs = new Set();
// Helper to turn heading text into an SEO-friendly URL slug
const slugify = (text) => {
let baseSlug = text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
if (!baseSlug) baseSlug = 'section';
let finalSlug = baseSlug;
let counter = 2;
while (usedSlugs.has(finalSlug) || document.getElementById(finalSlug)) {
finalSlug = `${baseSlug}-${counter}`;
counter++;
}
usedSlugs.add(finalSlug);
return finalSlug;
};
headings.forEach((heading) => {
const headingText = heading.textContent.trim();
if (!heading.id) {
heading.id = slugify(headingText);
}
// Add clickable '#' permalink anchor inside heading
const anchorIcon = document.createElement('a');
anchorIcon.href = `#${heading.id}`;
anchorIcon.className = 'heading-anchor';
anchorIcon.setAttribute('aria-label', `Permalink to ${headingText}`);
anchorIcon.textContent = '#';
heading.appendChild(anchorIcon);
// Create TOC List Item
const li = document.createElement('li');
const levelClass = heading.tagName.toLowerCase() === 'h3' ? 'toc-item-h3' : 'toc-item-h2';
li.className = `toc-item ${levelClass}`;
const a = document.createElement('a');
a.href = `#${heading.id}`;
a.className = 'toc-link';
a.dataset.target = heading.id;
a.textContent = headingText;
li.appendChild(a);
tocList.appendChild(li);
});
// ==========================================================================
// 3. Collapsible TOC Toggle Button
// ==========================================================================
if (tocToggleBtn && tocListWrap) {
tocToggleBtn.addEventListener('click', () => {
const isCollapsed = tocListWrap.classList.toggle('collapsed');
tocToggleBtn.setAttribute('aria-expanded', !isCollapsed);
tocToggleBtn.textContent = isCollapsed ? 'Show' : 'Hide';
});
}
// ==========================================================================
// 4. Active Scroll-Spy via IntersectionObserver API
// ==========================================================================
const tocLinks = document.querySelectorAll('.toc-link');
const setActiveLink = (id) => {
tocLinks.forEach((link) => {
const isMatch = link.dataset.target === id;
link.classList.toggle('active', isMatch);
});
};
const observerOptions = {
root: null,
// Triggers when heading crosses the top 15% to 30% zone of the viewport
rootMargin: '-12% 0px -72% 0px',
threshold: 0
};
const headingObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveLink(entry.target.id);
}
});
}, observerOptions);
headings.forEach((heading) => headingObserver.observe(heading));
// ==========================================================================
// 5. Reading Progress Bar & Back-to-Top Visibility
// ==========================================================================
const updateScrollProgress = () => {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const docHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight;
const progress = docHeight > 0 ? Math.min(100, Math.max(0, (scrollTop / docHeight) * 100)) : 0;
if (readingProgressBar) {
readingProgressBar.style.width = `${progress}%`;
}
if (scrollPercentBadge) {
scrollPercentBadge.textContent = `${Math.round(progress)}%`;
}
if (backToTopBtn) {
backToTopBtn.hidden = scrollTop < 350;
}
};
window.addEventListener('scroll', updateScrollProgress, { passive: true });
updateScrollProgress();
if (backToTopBtn) {
backToTopBtn.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
});
How to Build an Automatic Table of Contents with Scroll-Spy & Reading Progress Bar in HTML, CSS & JS
Long-form tutorials and programming guides rank significantly better in search engines when readers can scan the structure of the article at a glance and jump straight to the exact section they need. Moreover, when Google crawls a page containing a properly linked Table of Contents with descriptive anchor IDs (#section-slug), it frequently rewards the page in search results with Jump-To Sitelinks directly underneath the meta description.
However, manually writing <a href="#section"> lists for every blog post is tedious, and installing bulky WordPress Table of Contents plugins adds unnecessary database queries and frontend bloat. In this guide, we will examine how our zero-dependency Automatic Table of Contents & Reading Progress Engine works and how to deploy it on any static website or WordPress blog in minutes.
1. Automatic Heading Discovery & Duplicate-Safe Slug Generation
Instead of requiring authors to manually type id="" attributes onto every <h2> and <h3> tag, our script queries article.querySelectorAll('h2, h3') on page load and passes each heading’s text through a collision-safe slugify() function.
What happens if an article has two subheadings with the exact same title—for example, two sections named "Step 1: Installation"? A naive script would assign id="step-1-installation" to both headings, breaking HTML validity and causing anchor links to always jump to the first heading.
Our generator solves this using a JavaScript Set (usedSlugs):
JavaScript
while (usedSlugs.has(finalSlug) || document.getElementById(finalSlug)) {
finalSlug = `${baseSlug}-${counter}`;
counter++;
}
usedSlugs.add(finalSlug);
If "step-1-installation" already exists on the page, the second instance is automatically assigned "step-1-installation-2", guaranteeing 100% unique anchor targets across the entire document.
2. Lag-Free Scroll-Spy Using IntersectionObserver
Older scroll-spy scripts attach a heavy listener to the window.onscroll event and call getBoundingClientRect() on every heading dozens of times per second. On mobile devices, querying DOM geometry on every scroll frame triggers layout thrashing and battery drain.
Instead, our implementation uses the browser’s native IntersectionObserver API with a customized rootMargin:
JavaScript
const observerOptions = {
root: null,
rootMargin: '-12% 0px -72% 0px',
threshold: 0
};
By shrinking the top of the observation box by 12% (to account for the sticky header) and the bottom by 72%, we create an invisible horizontal “reading band” near the top of the screen. Whenever a heading enters that active reading band, the browser asynchronously notifies our callback and highlights the matching link in the Table of Contents—with zero layout recalculations on the main thread.
3. Preventing Sticky Headers from Covering Anchor Targets
One of the most common CSS frustrations with sticky navigation bars is that clicking an anchor link (#section-2) scrolls the target heading to the very top pixel of the browser window (top: 0), hiding the heading underneath your sticky navbar!
Instead of using messy JavaScript scroll offsets or invisible pseudo-element padding hacks, modern CSS solves this natively with a single property on the <html> root element:
CSS
html {
scroll-behavior: smooth;
scroll-padding-top: calc(var(--header-height) + 28px);
}
scroll-padding-top instructs the browser to leave a clean 96px breathing buffer above any #anchor target whenever a visitor clicks a Table of Contents link or loads a URL containing a hash fragment.
4. Bonus: How to Auto-Insert This TOC in WordPress (Without Plugins)
If you want to add this automatic Table of Contents and Reading Progress Bar to your WordPress posts without installing a plugin, drop this PHP snippet into your theme’s functions.php file (or a code snippets manager). It automatically injects the TOC container right before the first <h2> heading on single posts:
PHP
<?php
/**
* Automatically Inject NitroX Table of Contents Before First H2 in WordPress Posts
*/
function nitrox_auto_inject_toc( $content ) {
if ( is_single() && in_the_loop() && is_main_query() ) {
// Only inject if the post has at least 2 H2 headings
if ( substr_count( strtolower( $content ), '<h2' ) >= 2 ) {
$toc_markup = '
<div class="progress-container" aria-hidden="true">
<div id="readingProgressBar" class="progress-bar"></div>
</div>
<div class="toc-card" id="tocCard" style="margin: 2rem 0;">
<div class="toc-header">
<div class="toc-title-wrap">
<h2>Table of Contents</h2>
</div>
<button id="tocToggleBtn" class="toc-toggle-btn" type="button" aria-expanded="true">Hide</button>
</div>
<nav id="tocListWrap" class="toc-nav" aria-label="Table of Contents">
<ul id="tocList" class="toc-list"></ul>
</nav>
</div>';
// Insert TOC right before the first <h2> tag
$pos = stripos( $content, '<h2' );
if ( $pos !== false ) {
$content = substr_replace( $content, $toc_markup . '<h2', $pos, 3 );
}
}
}
return $content;
}
add_filter( 'the_content', 'nitrox_auto_inject_toc' );
Note for WordPress Users: In
script.js, simply changeconst article = document.getElementById('articleContent');toconst article = document.querySelector('#articleContent, .entry-content, article');so the script automatically finds your WordPress theme’s post content wrapper.
