Enhance Your Blog with Dynamic TOC, ScrollSpy, and Code Blocks Using Vanilla JS
If you’re building a blog or documentation site, you’ve probably faced these recurring challenges:
- Long pages with many headings, making it hard to navigate.
- Code blocks that need syntax highlighting, copy buttons, and sticky headers.
- Highlighting the currently visible section as you scroll (“scrollspy” behavior).
- A simple “back to top” button that doesn’t break the flow.
Today, I’ll walk you through a modular, object-oriented JavaScript solution that solves all of these in one script — without needing jQuery or heavy plugins.
Overview of Features
Our script provides:
Syntax highlighting and copyable code blocks
- Detects the language and adds a label.
- Adds a copy button with feedback (
Copied!/Failed). - Handles dynamically added code blocks automatically.
Dynamic Table of Contents (TOC)
- Generates a TOC based on headings (
h1-h6) inside#content. - Assigns IDs automatically using a slugified version of the heading text.
- Supports Bootstrap’s
list-groupstyling for clean UI.
- Generates a TOC based on headings (
ScrollSpy
- Highlights the current heading in the TOC while scrolling.
- Uses
IntersectionObserverfor efficient, modern scroll detection. - Works with dynamic content and updates automatically.
Back to Top Button
- Smooth scrolling.
- Show/hide behavior when scrolling past a threshold.
Reading Time Calculation
- Computes estimated reading time based on words per minute.
Modular, Object-Oriented Structure
We use object literals for each major feature, making the script easy to maintain and extend.
1. Utilities
// ======================
// Utilities
// ======================
const Utils = {
slugify(text) {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-');
},
calculateReadingTime(element) {
if (!element) return 0;
const wordsPerMinute = 225;
const text = element.innerText.trim().replace(/\s+/g, ' ');
const words = text.split(' ').length;
const minutes = Math.ceil(words / wordsPerMinute);
return minutes < 1 ? '< 1 min read' : `${minutes} min read`;
},
};These helper functions are shared across the modules.
2. Code Blocks Module
// ======================
// Code Blocks
// ======================
const CodeBlocks = {
init() {
this.wrapAll();
this.observe();
},
wrapAll() {
document.querySelectorAll('pre:not(.has-wrapper)').forEach((pre) => {
pre.classList.add('has-wrapper');
const code = pre.querySelector('code');
let language = 'Code';
if (code) {
const langClass = Array.from(code.classList).find((cls) =>
cls.startsWith('language-'),
);
if (langClass)
language = langClass.replace('language-', '').toUpperCase();
}
const wrapper = document.createElement('div');
wrapper.className = 'pre-wrapper';
const header = document.createElement('div');
header.className = 'pre-header';
const langLabel = document.createElement('div');
langLabel.className = 'language-label';
langLabel.textContent = `</> ${language}`;
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.textContent = 'Copy';
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(code.innerText);
const original = copyBtn.textContent;
copyBtn.textContent = 'Copied!';
setTimeout(() => (copyBtn.textContent = original), 2000);
} catch {
copyBtn.textContent = 'Failed';
setTimeout(() => (copyBtn.textContent = 'Copy'), 1500);
}
});
header.append(langLabel, copyBtn);
// Insert wrapper before pre, then move pre inside wrapper
pre.parentNode.insertBefore(wrapper, pre);
wrapper.append(header, pre);
});
},
observe() {
const observer = new MutationObserver((mutations, obs) => {
let foundNew = false;
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (
node.nodeType === 1 &&
(node.matches('pre') || node.querySelector('pre'))
)
foundNew = true;
});
});
if (foundNew) {
obs.disconnect();
this.wrapAll();
TOC.generate();
obs.observe(document.body, { childList: true, subtree: true });
}
});
observer.observe(document.body, { childList: true, subtree: true });
},
};wrapAll()wraps<pre>blocks only once (.has-wrapperensures no duplicates).observe()listens for DOM changes and automatically wraps new code blocks.
3. Table of Contents (TOC)
// ======================
// Table of Contents
// ======================
const TOC = {
init() {
this.generate();
ScrollSpy.init();
},
generate() {
const tocList = document.getElementById('toc-list');
if (!tocList) return;
tocList.innerHTML = '';
const headings = document.querySelectorAll(
'#content h1, #content h2, #content h3, #content h4, #content h5, #content h6',
);
headings.forEach((heading) => {
if (!heading.id) heading.id = Utils.slugify(heading.textContent);
const a = document.createElement('a');
a.href = `#${heading.id}`;
a.textContent = heading.textContent;
a.className =
'list-group-item list-group-item-action text-decoration-none link-light';
tocList.appendChild(a);
});
},
};- Generates a clean TOC for
#contentheadings. - Links are automatically styled as Bootstrap
list-group-item. - Works in tandem with the ScrollSpy module.
4. ScrollSpy
// ======================
// ScrollSpy
// ======================
const ScrollSpy = {
navLinks: new Map(),
activeId: null,
init() {
const tocList = document.getElementById('toc-list');
if (!tocList) return;
this.navLinks.clear();
tocList.querySelectorAll('a[href^="#"]').forEach((link) => {
const id = link.hash.slice(1);
if (id) this.navLinks.set(id, link);
});
const content = document.getElementById('content');
if (!content) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const { id } = entry.target;
if (!id || id === this.activeId) continue;
this.clearActive();
this.activate(this.navLinks.get(id));
this.activeId = id;
break;
}
},
{ rootMargin: '-0px 0px -90% 0px', threshold: 0 },
);
content
.querySelectorAll('[id]')
.forEach((section) => observer.observe(section));
},
clearActive() {
this.navLinks.forEach((link) => link.classList.remove('active'));
},
activate(link) {
if (!link) return;
link.classList.add('active');
},
};- Uses
IntersectionObserverfor efficient scroll detection. - Highlights only the currently visible section.
- Updates automatically if your TOC is regenerated.
5. Back to Top Button
// ======================
// Back To Top Button
// ======================
const BackToTop = {
init() {
const btn = document.querySelector('.backToTop');
if (!btn) return;
btn.addEventListener('click', () =>
window.scrollTo({ top: 0, behavior: 'smooth' }),
);
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
window.requestAnimationFrame(() => {
btn.classList.toggle('show', window.scrollY > 400);
ticking = false;
});
ticking = true;
}
});
},
};- Shows the button after scrolling down 400px.
- Smooth scrolling behavior makes it feel natural.
Initialization
Finally, everything comes together on DOMContentLoaded:
// ======================
// Initialization
// ======================
document.addEventListener('DOMContentLoaded', () => {
hljs.highlightAll();
CodeBlocks.init();
TOC.init();
const readTimeEl = document.getElementById('read-time');
if (readTimeEl)
readTimeEl.textContent = Utils.calculateReadingTime(
document.getElementById('content'),
);
BackToTop.init();
});
Why This Approach Works
- Modular & Object-Oriented: Each feature is isolated — easy to maintain.
- Vanilla JS Only: No jQuery or heavy libraries.
- Dynamic Content Friendly: MutationObserver ensures newly added code blocks or headings are handled automatically.
- Accessible & Semantic: Uses proper IDs,
ariaconsiderations can be added easily. - Bootstrap Compatible: Uses
list-groupfor TOC and can be themed with Bootstrap classes or custom CSS.
Example TOC Styling
#toc-list .list-group-item.active {
background-color: #0d6efd;
color: #fff;
font-weight: 500;
}
#toc-list .list-group-item:hover {
background-color: #0b5ed7;
color: #fff;
}- Makes the active TOC link stand out.
- Hover effect improves discoverability.
Conclusion
With this script, you can:
- Highlight and copy code effortlessly.
- Navigate long articles via a dynamic TOC.
- Keep track of where you are with a modern scrollspy.
- Show reading time and a convenient back-to-top button.
All with a modular, maintainable, and dependency-free approach.