Skip to content

Commit 050f474

Browse files
Gowiemclaude
andauthored
feat: add floating table of contents for blog posts (#88)
## what - Implements a polished floating table of contents (TOC) that appears on the left side of blog posts - Only displays H2 headings for cleaner, focused navigation - TOC appears via smooth slide-in animation when user scrolls to the article content - Maintains consistent styling and spacing to prevent jittery text reflow - Automatically highlights the current section as user scrolls (scroll spy) - Responsive design that hides on smaller screens (< 1400px) <img width="2460" height="1888" alt="Arc 2025-10-29 23 10 01" src="https://github.com/user-attachments/assets/a872d575-3a84-4055-8783-d114f0371e8e" /> ## why - Blog posts benefit from a persistent, easy-to-access navigation aid - Helps readers quickly jump to sections they're interested in - Professional implementation matches common patterns used by other tech blogs - Improves user experience and reduces scrolling fatigue on long articles - Provides better SEO and accessibility for blog content ## technical details ### Files Added - `assets/js/floating-toc.js` - Scroll detection, active link tracking, and visibility management - `layouts/partials/floating-toc.html` - Hugo partial for TOC rendering ### Files Modified - `config.yaml` - Updated tableOfContents to only generate H2 entries (endLevel: 2) - `assets/css/custom.scss` - Added comprehensive styling for floating TOC with smooth animations - `layouts/_default/single.html` - Integrated floating TOC partial into blog post template - `layouts/partials/scripts.html` - Added conditional script inclusion for blog posts ### Key Implementation Details - TOC visibility triggers at article content start (200px offset for header buffer) - Active state maintains consistent spacing by keeping border-left always present (transparent by default) - Font weight remains constant (600) to prevent text reflow - Scroll events are throttled (50ms) for performance - Uses Intersection Observer API for scroll spy with requestAnimationFrame fallback ## testing recommendations - [x] View blog posts on desktop (> 1400px width) - [x] Verify TOC appears smoothly after scrolling past header - [x] Test that links navigate to correct sections - [x] Verify no text jitter when sections become active - [x] Check that long heading text wraps appropriately - [x] Verify responsive behavior on tablets/mobile (should be hidden) - [x] Test with articles containing varying numbers of H2 headings ## references - Branch: `conductor/blog-floating-toc` - Inspired by professional blog implementations (Spacelift, etc.) --------- Co-authored-by: Claude <[email protected]>
1 parent 65e1745 commit 050f474

File tree

6 files changed

+340
-0
lines changed

6 files changed

+340
-0
lines changed

assets/css/custom.scss

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3062,6 +3062,129 @@ footer {
30623062
}
30633063
}
30643064

3065+
// Floating Table of Contents
3066+
.floating-toc {
3067+
position: fixed;
3068+
left: 2rem;
3069+
top: 120px;
3070+
width: 260px;
3071+
max-height: calc(100vh - 160px);
3072+
overflow-y: auto;
3073+
z-index: 40;
3074+
opacity: 0;
3075+
visibility: hidden;
3076+
transform: translateX(-20px);
3077+
transition: opacity 0.4s ease, visibility 0.4s ease, transform 0.4s ease;
3078+
3079+
// Show when scrolled past header
3080+
&.visible {
3081+
opacity: 1;
3082+
visibility: visible;
3083+
transform: translateX(0);
3084+
}
3085+
3086+
// Hide on mobile and tablets (adjust to show on larger screens)
3087+
@media (max-width: 1400px) {
3088+
display: none;
3089+
}
3090+
3091+
.toc-nav {
3092+
background: rgba(255, 255, 255, 0.98);
3093+
border: 2px solid #e8e8e8;
3094+
border-radius: 6px;
3095+
padding: 0.9rem 1rem;
3096+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
3097+
}
3098+
3099+
.toc-title {
3100+
font-size: 0.7rem;
3101+
font-weight: 800;
3102+
text-transform: uppercase;
3103+
letter-spacing: 0.05em;
3104+
color: $pine;
3105+
margin-bottom: 0.9rem;
3106+
padding-bottom: 0.6rem;
3107+
border-bottom: 2px solid $highlight;
3108+
}
3109+
3110+
.toc-content {
3111+
3112+
// Hugo generates nested ul/li structure
3113+
ul, ol {
3114+
list-style: none !important;
3115+
list-style-type: none !important;
3116+
padding-left: 0 !important;
3117+
margin-left: 0 !important;
3118+
list-style: none;
3119+
padding-left: 0;
3120+
margin: 0;
3121+
3122+
li {
3123+
list-style: none !important;
3124+
list-style-type: none !important;
3125+
3126+
&::before {
3127+
display: none !important;
3128+
content: none !important;
3129+
}
3130+
3131+
// Add subtle separator between items
3132+
&:not(:last-child) {
3133+
border-bottom: 1px solid #f0f0f0;
3134+
}
3135+
3136+
> a {
3137+
font-weight: 600;
3138+
color: #555;
3139+
text-decoration: none;
3140+
display: block;
3141+
padding: 0.3rem 0.4rem;
3142+
padding-left: calc(0.4rem - 3px);
3143+
border-radius: 4px;
3144+
border-left: 3px solid transparent;
3145+
transition: color 0.2s ease, background 0.2s ease, border-left-color 0.2s ease;
3146+
line-height: 1.25;
3147+
3148+
&:hover {
3149+
color: $highlight;
3150+
background: rgba(0, 224, 224, 0.08);
3151+
text-decoration: none;
3152+
}
3153+
3154+
&.active {
3155+
color: $highlight;
3156+
background: rgba(0, 224, 224, 0.12);
3157+
border-left-color: $highlight;
3158+
}
3159+
}
3160+
3161+
// Hide all nested lists (H3, H4, etc.)
3162+
> ul {
3163+
display: none !important;
3164+
}
3165+
}
3166+
}
3167+
}
3168+
3169+
// Custom scrollbar styling
3170+
&::-webkit-scrollbar {
3171+
width: 5px;
3172+
}
3173+
3174+
&::-webkit-scrollbar-track {
3175+
background: transparent;
3176+
}
3177+
3178+
&::-webkit-scrollbar-thumb {
3179+
background: rgba(0, 224, 224, 0.3);
3180+
border-radius: 3px;
3181+
3182+
&:hover {
3183+
background: rgba(0, 224, 224, 0.5);
3184+
}
3185+
}
3186+
}
3187+
30653188
// Header anchor links for blog posts (H1-H6)
30663189
// Entire heading is clickable with # symbol on the right and underline animation
30673190
.heading-with-anchor {

assets/js/floating-toc.js

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/**
2+
* Floating Table of Contents with Scroll Spy
3+
* Highlights the current section as user scrolls through the page
4+
*/
5+
(function () {
6+
"use strict";
7+
8+
// Wait for DOM to be ready
9+
if (document.readyState === "loading") {
10+
document.addEventListener("DOMContentLoaded", initTOC);
11+
} else {
12+
initTOC();
13+
}
14+
15+
function initTOC() {
16+
const toc = document.querySelector(".floating-toc");
17+
if (!toc) return; // Exit if no TOC on page
18+
19+
const tocLinks = toc.querySelectorAll("a");
20+
if (tocLinks.length === 0) return;
21+
22+
// Handle TOC visibility based on scroll position
23+
function updateTOCVisibility() {
24+
const scrollPosition =
25+
window.pageYOffset || document.documentElement.scrollTop;
26+
27+
// Find the article content area to determine when to show TOC
28+
const articleContent = document.querySelector(".singleContent");
29+
30+
if (articleContent) {
31+
// Show TOC once user scrolls past the article start
32+
const articleTop =
33+
articleContent.getBoundingClientRect().top + window.pageYOffset;
34+
const triggerPoint = articleTop - 200; // Show 200px before content
35+
36+
if (scrollPosition > triggerPoint) {
37+
toc.classList.add("visible");
38+
} else {
39+
toc.classList.remove("visible");
40+
}
41+
} else {
42+
// Fallback: show after scrolling 600px (roughly past banner)
43+
if (scrollPosition > 600) {
44+
toc.classList.add("visible");
45+
} else {
46+
toc.classList.remove("visible");
47+
}
48+
}
49+
}
50+
51+
// Initialize visibility on page load
52+
updateTOCVisibility();
53+
54+
// Update visibility on scroll (throttled for performance)
55+
let visibilityTimeout;
56+
window.addEventListener(
57+
"scroll",
58+
function () {
59+
if (!visibilityTimeout) {
60+
visibilityTimeout = setTimeout(function () {
61+
updateTOCVisibility();
62+
visibilityTimeout = null;
63+
}, 50);
64+
}
65+
},
66+
{ passive: true },
67+
);
68+
69+
// Get all H2 heading elements that have IDs (these are TOC targets)
70+
const headings = Array.from(document.querySelectorAll("h2[id]")).filter(
71+
(heading) => {
72+
// Only include headings in the main content area
73+
const singleContent = document.querySelector(".singleContent");
74+
return singleContent && singleContent.contains(heading);
75+
},
76+
);
77+
78+
if (headings.length === 0) return;
79+
80+
// Smooth scroll when clicking TOC links
81+
tocLinks.forEach((link) => {
82+
link.addEventListener("click", function (e) {
83+
const href = this.getAttribute("href");
84+
if (href && href.startsWith("#")) {
85+
e.preventDefault();
86+
const targetId = href.substring(1);
87+
const targetElement = document.getElementById(targetId);
88+
89+
if (targetElement) {
90+
const headerHeight = 100; // Adjust based on your fixed header height
91+
const targetPosition =
92+
targetElement.getBoundingClientRect().top +
93+
window.pageYOffset -
94+
headerHeight;
95+
96+
window.scrollTo({
97+
top: targetPosition,
98+
behavior: "smooth",
99+
});
100+
}
101+
}
102+
});
103+
});
104+
105+
// Intersection Observer for scroll spy
106+
const observerOptions = {
107+
rootMargin: "-100px 0px -66%",
108+
threshold: 0,
109+
};
110+
111+
let activeHeading = null;
112+
113+
const observer = new IntersectionObserver((entries) => {
114+
entries.forEach((entry) => {
115+
if (entry.isIntersecting) {
116+
const id = entry.target.id;
117+
activeHeading = id;
118+
updateActiveTOCLink(id);
119+
}
120+
});
121+
}, observerOptions);
122+
123+
// Observe all headings
124+
headings.forEach((heading) => observer.observe(heading));
125+
126+
// Update active link styling
127+
function updateActiveTOCLink(activeId) {
128+
// Remove active class from all links
129+
tocLinks.forEach((link) => link.classList.remove("active"));
130+
131+
// Add active class to current link
132+
if (activeId) {
133+
const activeLink = toc.querySelector(`a[href="#${activeId}"]`);
134+
if (activeLink) {
135+
activeLink.classList.add("active");
136+
137+
// Scroll the TOC if needed to keep active link visible
138+
const tocNav = toc.querySelector(".toc-nav");
139+
if (tocNav) {
140+
const linkRect = activeLink.getBoundingClientRect();
141+
const navRect = tocNav.getBoundingClientRect();
142+
143+
if (
144+
linkRect.bottom > navRect.bottom ||
145+
linkRect.top < navRect.top
146+
) {
147+
activeLink.scrollIntoView({
148+
block: "nearest",
149+
behavior: "smooth",
150+
});
151+
}
152+
}
153+
}
154+
}
155+
}
156+
157+
// Handle initial scroll position on page load
158+
function setInitialActiveLink() {
159+
const scrollPosition = window.pageYOffset;
160+
let currentHeading = null;
161+
162+
for (let i = headings.length - 1; i >= 0; i--) {
163+
const heading = headings[i];
164+
if (heading.offsetTop <= scrollPosition + 150) {
165+
currentHeading = heading.id;
166+
break;
167+
}
168+
}
169+
170+
if (currentHeading) {
171+
updateActiveTOCLink(currentHeading);
172+
}
173+
}
174+
175+
// Set initial active state
176+
setInitialActiveLink();
177+
178+
// Fallback: throttled scroll event listener for browsers with poor Intersection Observer support
179+
let activeLinkTimeout;
180+
window.addEventListener(
181+
"scroll",
182+
function () {
183+
if (activeLinkTimeout) {
184+
window.cancelAnimationFrame(activeLinkTimeout);
185+
}
186+
187+
activeLinkTimeout = window.requestAnimationFrame(function () {
188+
setInitialActiveLink();
189+
});
190+
},
191+
{ passive: true },
192+
);
193+
}
194+
})();

config.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ markup:
4646
hardWraps: false
4747
unsafe: true
4848
xhtml: false
49+
tableOfContents:
50+
startLevel: 2
51+
endLevel: 2
52+
ordered: false
4953
menu:
5054
main:
5155
- name: Home

layouts/_default/single.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
<main>
66
<div class="container">
7+
{{ if eq .Section "blog" }}
8+
{{ partial "floating-toc.html" . }}
9+
{{ end }}
710

811
<div class="row">
912
<div class="col col-12">

layouts/partials/floating-toc.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{{ if .TableOfContents }}
2+
<aside class="floating-toc">
3+
<nav class="toc-nav">
4+
<h4 class="toc-title">Table of Contents</h4>
5+
<div class="toc-content">
6+
{{ .TableOfContents }}
7+
</div>
8+
</nav>
9+
</aside>
10+
{{ end }}

layouts/partials/scripts.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,10 @@
2424
Defer.dom("img.lazy", 200, "loaded");
2525
</script>
2626

27+
{{ if eq .Section "blog" }}
28+
{{ $tocJS := resources.Get "js/floating-toc.js" }}
29+
{{ $secureTocJS := $tocJS | resources.Minify }}
30+
<script src="{{ $secureTocJS.Permalink }}"></script>
31+
{{ end }}
32+
2733
{{ partial "lightbox.html" .}}

0 commit comments

Comments
 (0)