Rail TOC
A table of contents with an arrow that rides a wavy rail to the section you are reading.
Preview
Installation
pnpm dlx shadcn@latest add https://makeui.dev/r/rail-toc.jsonDependencies
- motion
Interaction
Scroll and the arrow travels along the rail between headings, following the bends. Scroll back up and it turns around. The rail behind it fills in as headings are passed, and the rest stays dashed. Click any heading to scroll to it.
Props
| Prop | Type | Description |
|---|---|---|
| items* | { id; label; depth? }[] | Headings in document order. Each id must match an element id in the content. |
| containerRef | RefObject<HTMLElement | null> | Scroll container to track. Defaults to the window. |
| offset | number | Pixels below the scroller's top edge where a heading counts as reached. Defaults to 96. |
| indent | number | Horizontal step in pixels per depth level. Defaults to 12. |
| title | string | Text above the list and the nav label. Defaults to "On this page". |
| className | string | Extra classes merged onto the root element. |
How to use
import RailToc from "@/components/ui/rail-toc"
const items = [
{ id: "install", label: "Installation" },
{ id: "usage", label: "Usage" },
]
export function Docs() {
return <RailToc items={items} />
}Source code
"use client"
import {
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react"
import { motion, useMotionValue, useSpring, useTransform } from "motion/react"
import { cn } from "@/lib/utils"
type Item = { id: string; label: string; depth?: number }
const ROW = 40 // height of one list row, in px
const RAIL = 20 // width of the rail column
const XS = [5, 13] // the rail swings between these x positions
function railPath(n: number) {
const pts = Array.from({ length: n }, (_, i) => ({
x: XS[i % 2],
y: i * ROW + ROW / 2,
}))
let d = `M${pts[0].x} ${pts[0].y}`
for (let i = 1; i < n; i++) {
const a = pts[i - 1]
const b = pts[i]
const mid = (a.y + b.y) / 2
d += ` C${a.x} ${mid} ${b.x} ${mid} ${b.x} ${b.y}`
}
return d
}
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
export default function RailToc({
items,
containerRef,
offset = 96,
indent = 12,
title = "On this page",
className,
}: {
items: Item[]
containerRef?: RefObject<HTMLElement | null>
offset?: number
indent?: number
title?: string
className?: string
}) {
const n = items.length
const d = n > 1 ? railPath(n) : ""
const pathRef = useRef<SVGPathElement>(null)
const prev = useRef(0)
const [active, setActive] = useState(0)
const progress = useMotionValue(0) // 0 .. n-1, fractional between headings
const smooth = useSpring(progress, { stiffness: 170, damping: 26, mass: 0.6 })
const turn = useMotionValue(0) // 0 when moving down, 180 when moving up
const turnSmooth = useSpring(turn, { stiffness: 300, damping: 22 })
const length = useMotionValue(0)
const arrow = useTransform([smooth, turnSmooth, length], ([p, t, L]: number[]) => {
const el = pathRef.current
if (!el || !L) return `translate(${XS[0]}px, ${ROW / 2}px) rotate(90deg)`
const at = clamp(p / Math.max(n - 1, 1), 0, 1) * L
const pos = el.getPointAtLength(at)
const a = el.getPointAtLength(Math.min(at, L - 1))
const b = el.getPointAtLength(Math.min(at, L - 1) + 1)
const angle = (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI
return `translate(${pos.x}px, ${pos.y}px) rotate(${angle + t}deg)`
})
const filled = useTransform(smooth, (p) => clamp(p / Math.max(n - 1, 1), 0, 1))
useLayoutEffect(() => {
if (pathRef.current) length.set(pathRef.current.getTotalLength())
}, [d, length])
const update = useCallback(() => {
if (!n) return
const scroller = containerRef?.current ?? null
const top0 = scroller ? scroller.getBoundingClientRect().top : 0
const tops = items.map((it) => {
const el = document.getElementById(it.id)
return el ? el.getBoundingClientRect().top - top0 : Number.POSITIVE_INFINITY
})
let idx = -1
for (let i = 0; i < n; i++) if (tops[i] <= offset) idx = i
let p = 0
if (idx >= n - 1) p = n - 1
else if (idx >= 0) {
const span = tops[idx + 1] - tops[idx]
p = idx + (span > 0 ? clamp((offset - tops[idx]) / span, 0, 1) : 0)
}
const scrollable = scroller
? scroller.scrollHeight > scroller.clientHeight + 2
: document.documentElement.scrollHeight > window.innerHeight + 2
const atBottom = scroller
? scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2
: window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2
if (scrollable && atBottom) p = n - 1
if (p > prev.current + 0.001) turn.set(0)
else if (p < prev.current - 0.001) turn.set(180)
prev.current = p
progress.set(p)
setActive(Math.round(p))
}, [containerRef, items, n, offset, progress, turn])
useEffect(() => {
const target: HTMLElement | Window = containerRef?.current ?? window
let raf = 0
const onScroll = () => {
cancelAnimationFrame(raf)
raf = requestAnimationFrame(update)
}
update()
target.addEventListener("scroll", onScroll, { passive: true })
window.addEventListener("resize", onScroll)
return () => {
cancelAnimationFrame(raf)
target.removeEventListener("scroll", onScroll)
window.removeEventListener("resize", onScroll)
}
}, [containerRef, update])
const go = (id: string) => {
const el = document.getElementById(id)
if (!el) return
const behavior = matchMedia("(prefers-reduced-motion: reduce)").matches
? "auto"
: "smooth"
const scroller = containerRef?.current
if (scroller) {
const y =
el.getBoundingClientRect().top -
scroller.getBoundingClientRect().top +
scroller.scrollTop -
offset +
1
scroller.scrollTo({ top: y, behavior })
} else {
window.scrollTo({
top: el.getBoundingClientRect().top + window.scrollY - offset + 1,
behavior,
})
}
}
if (!n) return null
return (
<nav aria-label={title} className={cn("text-sm", className)}>
<p className="mb-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{title}
</p>
<div className="relative">
{n > 1 && (
<svg
width={RAIL}
height={n * ROW}
className="pointer-events-none absolute left-0 top-0 overflow-visible text-foreground"
aria-hidden
>
<path
ref={pathRef}
d={d}
fill="none"
stroke="currentColor"
strokeOpacity={0.3}
strokeWidth={1.5}
strokeDasharray="2 4"
strokeLinecap="round"
/>
<motion.path
d={d}
fill="none"
stroke="currentColor"
strokeOpacity={0.8}
strokeWidth={1.5}
strokeLinecap="round"
style={{ pathLength: filled }}
/>
<motion.g style={{ transform: arrow, transformOrigin: "0px 0px" }}>
<path d="M-5 -5.5 L7 0 L-5 5.5 L-2 0 Z" fill="currentColor" />
</motion.g>
</svg>
)}
<ul>
{items.map((it, i) => (
<li key={it.id} style={{ height: ROW }} className="flex items-center">
<button
type="button"
onClick={() => go(it.id)}
aria-current={i === active ? "location" : undefined}
style={{ paddingLeft: RAIL + 12 + (it.depth ?? 0) * indent }}
className={cn(
"w-full truncate text-left transition-colors",
i === active
? "font-medium text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{it.label}
</button>
</li>
))}
</ul>
</div>
</nav>
)
}