Mobile Table Of Content
A compact bottom-sheet table of contents for phones that tracks progress, surfaces the current heading, and expands when readers need orientation.
Code
tsx
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { AnimatedThemeToggler } from '../animated-theme-toggler'
interface Heading {
id: string
text: string
level: number
element: HTMLElement
}
interface MobileTableOfContentProps {
contentContainerId: string
topOffset?: number
}
function CircleProgress({ value }: { value: number }) {
const circumference = 2 * Math.PI * 10
const strokeDashoffset = circumference - (value || 0) / 100 * circumference
return (
<div className="relative w-5 h-5 shrink-0">
<svg className="w-5 h-5 transform -rotate-90">
<circle
className="text-muted stroke-current"
strokeWidth="2"
fill="transparent"
r="9"
cx="10"
cy="10"
/>
<circle
className="text-foreground stroke-current transition-all duration-300"
strokeWidth="2"
fill="transparent"
r="9"
cx="10"
cy="10"
strokeDasharray={circumference}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
/>
</svg>
</div>
)
}
const MobileTableOfContent: React.FC<MobileTableOfContentProps> = ({ contentContainerId, topOffset = 60 }) => {
const [headings, setHeadings] = useState<Heading[]>([])
const [activeId, setActiveId] = useState<string>('')
const [progress, setProgress] = useState(0)
const [isExpanded, setIsExpanded] = useState(false)
const [currentHeadingText, setCurrentHeadingText] = useState('')
const [isNearBottom, setIsNearBottom] = useState(false)
const tocRef = useRef<HTMLDivElement>(null)
const scrollListRef = useRef<HTMLDivElement>(null)
const scrollRafRef = useRef<number | null>(null)
const pillRef = useRef<HTMLDivElement>(null)
const scrollRootRef = useRef<HTMLElement | Window | null>(null)
// Extract headings from content
useEffect(() => {
const extractHeadings = () => {
const contentEl = document.getElementById(contentContainerId) as HTMLElement | null
if (!contentEl) return
const headingElements = Array.from(
contentEl.querySelectorAll('h1, h2, h3, h4, h5, h6')
) as HTMLElement[]
const extractedHeadings = headingElements.map((element, index) => {
const id = element.id || `heading-${index}`
if (!element.id) {
element.id = id
}
return {
id,
text: element.textContent || '',
level: parseInt(element.tagName.substring(1)),
element,
}
})
setHeadings(extractedHeadings)
// Initialize with first heading if available
if (extractedHeadings.length > 0) {
setCurrentHeadingText(extractedHeadings[0].text)
setActiveId(extractedHeadings[0].id)
}
}
// Run immediately and once again in next frame to catch async markdown paint.
extractHeadings()
const rafId = window.requestAnimationFrame(extractHeadings)
let attempts = 0
let retryRafId: number
const retry = () => {
attempts += 1
extractHeadings()
const contentEl = document.getElementById(contentContainerId) as HTMLElement | null
if (attempts >= 20 || (contentEl?.querySelector('h1, h2, h3, h4, h5, h6'))) {
return
}
retryRafId = window.requestAnimationFrame(retry)
}
retryRafId = 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 })
}
return () => {
window.cancelAnimationFrame(rafId)
window.cancelAnimationFrame(retryRafId)
observer.disconnect()
}
}, [contentContainerId])
// Handle scroll progress and Active Heading detection
const handleScroll = useCallback(() => {
if (headings.length === 0) return
if (scrollRafRef.current !== null) {
window.cancelAnimationFrame(scrollRafRef.current)
}
scrollRafRef.current = window.requestAnimationFrame(() => {
const contentEl = document.getElementById(contentContainerId) as HTMLElement | null
if (!contentEl) return
const scrollRoot = scrollRootRef.current ?? window
const scrollTop = scrollRoot instanceof Window ? window.scrollY : scrollRoot.scrollTop
const contentTop = contentEl.offsetTop
const contentHeight = contentEl.offsetHeight
const windowHeight = scrollRoot instanceof Window ? window.innerHeight : scrollRoot.clientHeight
const contentRect = contentEl.getBoundingClientRect()
const rootBottom = scrollRoot instanceof Window
? window.innerHeight
: scrollRoot.getBoundingClientRect().bottom
// 1. Calculate Progress
const contentScrollTop = Math.max(0, scrollTop - contentTop)
const scrollableDistance = contentHeight - windowHeight
let progressVal = 0
if (scrollableDistance > 0) {
progressVal = Math.min(100, Math.max(0, (contentScrollTop / scrollableDistance) * 100))
setProgress(progressVal)
}
const bottomGap = contentRect.bottom - rootBottom
// Only hide near the bottom after user has progressed meaningfully.
setIsNearBottom(scrollableDistance > 0 && bottomGap <= 140 && progressVal > 85)
// 2. Determine Active Heading
const upperBound = scrollTop + topOffset
let currentActive = headings[0]
let withinRange: Heading | null = null
for (let i = 0; i < headings.length; i++) {
const heading = headings[i]
const elementTop = heading.element.getBoundingClientRect().top + (scrollRoot instanceof Window ? window.scrollY : scrollRoot.scrollTop)
if (elementTop >= scrollTop && elementTop <= upperBound) {
withinRange = heading
break
}
if (elementTop <= upperBound) {
currentActive = heading
} else {
break
}
}
if (withinRange) {
currentActive = withinRange
}
if (currentActive) {
setActiveId(currentActive.id)
setCurrentHeadingText(currentActive.text)
}
scrollRafRef.current = null
})
}, [contentContainerId, headings, topOffset])
// 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])
// Auto-scroll to active item when expanded
useEffect(() => {
if (isExpanded && activeId && scrollListRef.current) {
const activeElement = document.getElementById(`toc-btn-${activeId}`)
if (activeElement) {
window.requestAnimationFrame(() => {
activeElement.scrollIntoView({ block: 'center', behavior: 'smooth' })
})
}
}
}, [isExpanded, activeId])
// Handle heading click
const scrollToHeading = (id: string) => {
const element = document.getElementById(id)
if (element) {
const root = scrollRootRef.current ?? window
const rootTop = root instanceof Window ? 0 : root.getBoundingClientRect().top
const currentScroll = root instanceof Window ? window.scrollY : root.scrollTop
const elementTop = element.getBoundingClientRect().top - rootTop + currentScroll
if (root instanceof Window) {
window.scrollTo({ top: elementTop - topOffset, behavior: 'smooth' })
} else {
root.scrollTo({ top: elementTop - topOffset, behavior: 'smooth' })
}
}
setIsExpanded(false)
}
// Close TOC when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isExpanded && tocRef.current && !tocRef.current.contains(event.target as Node)) {
setIsExpanded(false)
}
}
if (isExpanded) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [isExpanded])
if (headings.length === 0) return null
return (
<>
<style>{`
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
@keyframes slideUpFade {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-slide-up-fade {
animation: slideUpFade 0.3s ease-out forwards;
}
`}</style>
{/* Dynamic Island TOC Container */}
<div
ref={tocRef}
className={`fixed z-50 transition-all duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] bottom-[15px] px-[10px] md:px-0 w-full ${isExpanded
? 'left-1/2 -translate-x-1/2 md:max-w-[400px] max-w-[400px] h-[200px]'
: 'bottom-[15px] left-1/2 -translate-x-1/2 max-w-[300px] md:max-w-[300px] h-[45px]'
} ${isNearBottom ? 'opacity-0 pointer-events-none translate-y-4' : 'opacity-100'}`}
>
{/* The Pill / Card */}
<div
ref={pillRef}
className={`bg-white dark:bg-black text-foreground shadow-lg overflow-hidden h-full flex flex-col transition-all duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] border border-border/30 ${isExpanded
? 'rounded-[16px]'
: 'rounded-[30px] cursor-pointer'
}`}
onClick={() => !isExpanded && setIsExpanded(true)}
>
{/* Header Section (Always Visible) */}
{/* flex-shrink-0 ensures this stays 45px height regardless of container expansion */}
<div className={`flex items-center gap-3 shrink-0 h-[45px] w-full px-3 transition-all duration-300 ${isExpanded ? "opacity-100 border-b border-border/10" : "opacity-100"}`}>
<CircleProgress value={progress} />
{/* Current heading text area */}
<div className="flex-1 min-w-0 h-full relative overflow-hidden flex items-center">
{/* We use a key to trigger the animation when text changes */}
<div
key={currentHeadingText}
className="text-[12px] font-[500] truncate w-full animate-slide-up-fade absolute"
>
{currentHeadingText}
</div>
</div>
{isExpanded &&
<AnimatedThemeToggler size={18} className='shrink-0' />
}
</div>
{/* Expanded List Section */}
{/* flex-1 ensures it fills the remaining space, min-h-0 allows internal scrolling */}
<div className={`flex-1 min-h-0 w-full transition-opacity duration-300 ${isExpanded ? "opacity-100" : "opacity-0"}`}>
<div
ref={scrollListRef}
className="h-full w-full overflow-y-auto no-scrollbar px-2 py-2"
>
<div className="space-y-[2px]">
{headings.map((heading) => (
<button
key={heading.id}
id={`toc-btn-${heading.id}`}
onClick={(e) => {
e.stopPropagation()
scrollToHeading(heading.id)
}}
className={`w-full flex items-center rounded-[6px] text-start px-2 py-1.5 transition-colors ${activeId === heading.id
? 'bg-muted/10 font-[500] text-foreground'
: 'hover:bg-muted/5 font-[400] opacity-[0.7] hover:opacity-[1]'
}`}
>
{/* Indentation lines */}
<div className="flex shrink-0 mr-2">
{[...Array(heading.level - 1)].map((_, i) => (
<div
key={i}
className="w-[6px]" // Smaller indentation
/>))}
</div>
<span className="text-[12px] truncate leading-tight">{heading.text}</span>
</button>
))}
</div>
</div>
</div>
</div>
</div>
</>
)
}
export default MobileTableOfContentInstallation
bash
npx whitepapper add mobile-table-of-content --outdir src/components/uiUsage
tsx
import MarkdownRender from "@/components/ui/markdown-render/markdown-render";
import MobileTableOfContent from "@/components/ui/toc/mobileTableOfContent";
export default function PostPage() {
return (
<>
<MobileTableOfContent contentContainerId="mobile-docs-content" topOffset={60} />
<MarkdownRender
contentContainerId="mobile-docs-content"
content={`## Intro\n\nContent\n\n## Deep dive\n\nMore content`}
/>
</>
);
}Props
| Prop | Type | Description |
|---|---|---|
contentContainerId | string | Id of the content wrapper used to discover headings and track reading progress. |
topOffset | number | Offset used when scrolling to a heading after tapping it in the mobile sheet. |
Notes
This variant is built for dense reading on phones. It stays out of the way until needed, then expands into a compact heading navigator.