Lines Table Of Content
An expressive desktop-only table-of-content rail that turns heading navigation into a visual reading instrument on the right edge of the screen.
Code
tsx
import type { JSX } from 'astro/jsx-runtime';
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
interface Heading {
element: Element;
text: string;
level: number;
position: number;
id: string;
}
interface TableOfContentsProps {
contentContainerId: string;
}
export const LinesTableOfContent: React.FC<TableOfContentsProps> = ({ contentContainerId }) => {
const [headings, setHeadings] = useState<Heading[]>([]);
const [activeId, setActiveId] = useState<string>('');
const [isHovering, setIsHovering] = useState<boolean>(false);
const [hoverY, setHoverY] = useState<number | null>(null);
const tocRef = useRef<HTMLDivElement>(null);
const lineRefs = useRef<Array<HTMLDivElement | null>>([]);
const lineCount: number = 40;
const scrollRootRef = useRef<HTMLElement | Window | null>(null);
const scrollRafRef = useRef<number | null>(null);
const initialActiveSetRef = useRef<boolean>(false);
const headingLineMapRef = useRef<Map<number, number>>(new Map());
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
const distanceFromRight = window.innerWidth - event.clientX;
setIsHovering((prev) => {
const shouldExpand = prev ? distanceFromRight <= 400 : distanceFromRight <= 100;
return shouldExpand;
});
if (distanceFromRight <= 400) {
setHoverY(event.clientY);
} else {
setHoverY(null);
}
};
const handleMouseLeave = () => {
setIsHovering(false);
setHoverY(null);
};
window.addEventListener('mousemove', handleMouseMove, { passive: true });
window.addEventListener('mouseleave', handleMouseLeave);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseleave', handleMouseLeave);
};
}, []);
const getAbsoluteTop = useCallback((element: Element, root: HTMLElement | Window): number => {
const rect = element.getBoundingClientRect();
if (root instanceof Window) {
return rect.top + window.scrollY;
}
const rootRect = root.getBoundingClientRect();
return rect.top - rootRect.top + root.scrollTop;
}, []);
// Extract headings from content
useEffect(() => {
const extractHeadings = () => {
const contentEl = document.getElementById(contentContainerId) as HTMLElement | null;
if (!contentEl) return;
const elements = contentEl.querySelectorAll('h1, h2, h3, h4, h5, h6');
const root = scrollRootRef.current ?? document.getElementById('app-scroll-root') ?? window;
const contentAbsoluteTop = getAbsoluteTop(contentEl, root);
const contentHeight = Math.max(1, contentEl.scrollHeight);
const headingData: Heading[] = Array.from(elements).map((heading, index) => {
const id = heading.id || `heading-${index}`;
if (!heading.id) {
heading.id = id;
}
const headingAbsoluteTop = getAbsoluteTop(heading, root);
const relativeTop = (headingAbsoluteTop - contentAbsoluteTop) / contentHeight;
return {
element: heading,
text: heading.textContent || '',
level: parseInt(heading.tagName[1]),
position: Math.min(1, Math.max(0, relativeTop)),
id: id
};
});
setHeadings((prev) => {
if (
prev.length === headingData.length &&
prev.every((item, index) => item.id === headingData[index]?.id && item.text === headingData[index]?.text)
) {
return prev;
}
return headingData;
});
};
extractHeadings();
// Retry briefly for async markdown paint without a long-running interval.
let attempts = 0;
const retry = () => {
attempts += 1;
extractHeadings();
const contentEl = document.getElementById(contentContainerId) as HTMLElement | null;
if (attempts >= 10 || (contentEl?.querySelector('h1, h2, h3, h4, h5, h6'))) {
return;
}
window.requestAnimationFrame(retry);
};
window.requestAnimationFrame(retry);
const observer = new MutationObserver(extractHeadings);
const contentEl = document.getElementById(contentContainerId) as HTMLElement | null;
if (contentEl) {
observer.observe(contentEl, {
childList: true,
subtree: true,
characterData: true
});
}
return () => {
observer.disconnect();
};
}, [contentContainerId, getAbsoluteTop]);
const headingLineMap = useMemo(() => {
const map = new Map<number, number>();
if (headings.length === 0) return map;
const maxLineIndex = lineCount - 1;
const requestedGap = 1;
const maxPossibleGap = headings.length > 1
? Math.floor(maxLineIndex / (headings.length - 1))
: requestedGap;
const minLineGap = Math.max(1, Math.min(requestedGap, maxPossibleGap));
for (let i = 0; i < headings.length; i++) {
const heading = headings[i];
const remainingHeadings = headings.length - i - 1;
const minAllowed = i === 0 ? 0 : (map.get(i - 1) ?? 0) + minLineGap;
const maxAllowed = Math.max(minAllowed, maxLineIndex - (remainingHeadings * minLineGap));
const rawLine = Math.round(heading.position * maxLineIndex);
const lineIndex = Math.min(maxAllowed, Math.max(minAllowed, rawLine));
map.set(i, lineIndex);
}
return map;
}, [headings, lineCount]);
useEffect(() => {
headingLineMapRef.current = headingLineMap;
}, [headingLineMap]);
const headingIndexForLine = useCallback((lineIndex: number): number => {
for (let i = 0; i < headings.length; i++) {
if (headingLineMapRef.current.get(i) === lineIndex) {
return i;
}
}
return -1;
}, [headings]);
// Find closest heading to a line position
const findClosestHeadingIndex = useCallback((lineIndex: number): number => {
if (headings.length === 0) return -1;
let closestIndex = 0;
let smallestDistance = Math.abs((headingLineMapRef.current.get(0) ?? 0) - lineIndex);
for (let i = 1; i < headings.length; i++) {
const mappedLine = headingLineMapRef.current.get(i) ?? 0;
const distance = Math.abs(mappedLine - lineIndex);
if (distance < smallestDistance) {
smallestDistance = distance;
closestIndex = i;
}
}
return closestIndex;
}, [headings]);
// Handle scroll to determine active heading and active normal line
const handleScroll = useCallback(() => {
if (headings.length === 0) return;
if (scrollRafRef.current !== null) {
window.cancelAnimationFrame(scrollRafRef.current);
}
scrollRafRef.current = window.requestAnimationFrame(() => {
const scrollRoot = scrollRootRef.current ?? window;
// Find active heading (the one that crossed 50px from top)
let activeHeading: Heading | null = null;
const rootTop = scrollRoot instanceof Window ? 0 : scrollRoot.getBoundingClientRect().top;
const thresholdTop = rootTop + 50;
for (let i = 0; i < headings.length; i++) {
const heading = headings[i];
const headingElement = heading.element as HTMLElement;
if (headingElement.getBoundingClientRect().top <= thresholdTop) {
activeHeading = heading;
} else {
break;
}
}
if (activeHeading) {
setActiveId(activeHeading.id);
} else if (headings.length > 0 && !initialActiveSetRef.current) {
// Set first heading as active on initial load if no active heading found
setActiveId(headings[0].id);
}
initialActiveSetRef.current = true;
scrollRafRef.current = null;
});
}, [headings]);
// Set initial active heading on mount
useEffect(() => {
if (headings.length > 0 && !initialActiveSetRef.current) {
const raf = window.requestAnimationFrame(handleScroll);
return () => window.cancelAnimationFrame(raf);
}
}, [headings, handleScroll]);
// Attach scroll listener
useEffect(() => {
handleScroll();
const root = document.getElementById('app-scroll-root') ?? window;
scrollRootRef.current = root;
if (root instanceof Window) {
window.addEventListener('scroll', handleScroll, { passive: true });
} else {
root.addEventListener('scroll', handleScroll, { passive: true });
}
return () => {
if (root instanceof Window) {
window.removeEventListener('scroll', handleScroll);
} else {
root.removeEventListener('scroll', handleScroll);
}
if (scrollRafRef.current !== null) {
window.cancelAnimationFrame(scrollRafRef.current);
}
};
}, [handleScroll]);
const handleLineClick = (lineIndex: number): void => {
if (headings.length === 0) return;
const closestHeadingIndex = headingIndexForLine(lineIndex);
const isHeadingLine = closestHeadingIndex !== -1;
if (!isHeadingLine || !headings[closestHeadingIndex]) return;
const root = scrollRootRef.current ?? window;
const heading = headings[closestHeadingIndex];
const headingElement = heading.element as HTMLElement;
const rootTop = root instanceof Window ? 0 : root.getBoundingClientRect().top;
const currentScroll = root instanceof Window ? window.scrollY : root.scrollTop;
const elementTop = headingElement.getBoundingClientRect().top - rootTop + currentScroll;
const offset = 20;
if (root instanceof Window) {
window.scrollTo({ top: elementTop - offset, behavior: 'smooth' });
} else {
root.scrollTo({ top: elementTop - offset, behavior: 'smooth' });
}
};
const renderLines = (): JSX.Element[] => {
const lines: JSX.Element[] = [];
for (let i = 0; i < lineCount; i++) {
const closestHeadingIndex = headingIndexForLine(i);
const isHeadingLine = closestHeadingIndex !== -1;
const isHeadingInHoverRadius = isHovering &&
hoverY !== null &&
!!lineRefs.current[i] &&
Math.abs(
(lineRefs.current[i]?.getBoundingClientRect().top ?? 0) +
((lineRefs.current[i]?.getBoundingClientRect().height ?? 0) / 2) -
hoverY
) <= 20;
// Check if this line corresponds to the active heading
const isActiveHeading = isHeadingLine &&
closestHeadingIndex !== -1 &&
headings[closestHeadingIndex]?.id === activeId;
// Width based on type and hover state
let width = 'w-[8px]'; // Default width for normal lines (not hovered)
if (isHeadingLine) {
width = 'w-[12px]'; // Heading lines are 10px wide (not hovered)
}
let height = 'h-[1px]'
// Color based on active state
let bgColor = 'bg-muted-foreground';
if (isActiveHeading) {
height = 'h-[3px]'
width = 'w-[20px]'; // Active heading line is wider
bgColor = 'bg-primary'; // Active heading gets primary color
}
// Override width on hover
if (isHovering) {
if (isHeadingLine) {
width = 'w-[20px]'; // Heading lines 14px on hover
} else {
width = 'w-[12px]'; // Normal lines 8px on hover
}
}
lines.push(
<div
key={i}
className={`relative flex items-center transition-all duration-300 ease-in-out justify-end w-full group gap-5 ${isHeadingLine && isHeadingInHoverRadius ? 'py-[15px]' : 'py-[5px]'}`}
onClick={(e) => {
e.stopPropagation();
if (isHeadingLine && headings[closestHeadingIndex]) {
handleLineClick(i);
}
}}
>
{/* Heading text on hover - with opacity transition */}
<div
ref={(el) => { lineRefs.current[i] = el; }}
className={`
cursor-pointer select-none
absolute right-0 mr-[35px]
max-w-[400px] truncate
transition-all duration-300 ease-in-out leading-[1em]
${isHeadingInHoverRadius
? 'text-[18px] font-[450] text-foreground '
: 'text-[10px] text-muted-foreground/80'}
${isHeadingLine && headings[closestHeadingIndex]
? isHovering
? 'opacity-100 cursor-pointer'
: 'opacity-0 pointer-events-none'
: 'opacity-0 pointer-events-none'
}
`}
>
{isHeadingLine && headings[closestHeadingIndex] && headings[closestHeadingIndex].text}
</div>
{/* Line indicator */}
<div
className={`
${width} ${height} ${bgColor} rounded-full ${isHeadingLine ? 'cursor-pointer' : ''}
transition-all ease-in-out duration-300
${isHeadingLine ? 'opacity-100' : 'opacity-80 hover:opacity-100'}
`}
onClick={(e) => {
if (isHeadingLine) {
e.stopPropagation();
handleLineClick(i);
}
}}
aria-label={isHeadingLine && headings[closestHeadingIndex] ? `Navigate to: ${headings[closestHeadingIndex].text}` : undefined}
/>
</div>
);
}
return lines;
};
return (
<div
ref={tocRef}
className={`fixed right-0 top-0 h-screen flex justify-end items-center z-50 transition-all ease-in-out duration-300 ${isHovering ? 'w-[400px]' : 'w-[100px]'}`}
>
{/* Table of contents */}
<div className="h-full max-w-[400px] flex flex-col justify-center py-19 pr-3">
<div className="flex flex-col justify-between h-full">
{renderLines()}
</div>
</div>
</div>
);
};Switch to desktop to see this table of content.
Installation
bash
npx whitepapper add lines-table-of-content --outdir src/components/uiUsage
tsx
import MarkdownRender from "@/components/ui/markdown-render/markdown-render";
import { LinesTableOfContent } from "@/components/ui/toc/linesTableOfContent";
export default function EssayPage() {
return (
<>
<LinesTableOfContent contentContainerId="essay-content" />
<MarkdownRender
contentContainerId="essay-content"
content={`## Opening\n\nContent\n\n## System design\n\nMore content\n\n## Closing\n\nWrap up`}
/>
</>
);
}Props
| Prop | Type | Description |
|---|---|---|
contentContainerId | string | Id of the markdown/content wrapper that contains headings to map into the line rail. |
Notes
The lines TOC is intentionally more expressive than a standard sidebar list. It is best on wide layouts where the right edge can act like an ambient reading instrument.