makeui
DocsComponents

© 2026 makeui. Open source, MIT licensed.

DocsComponentsGitHub

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.json

Dependencies

  • 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

PropTypeDescription
items*{ id; label; depth? }[]Headings in document order. Each id must match an element id in the content.
containerRefRefObject<HTMLElement | null>Scroll container to track. Defaults to the window.
offsetnumberPixels below the scroller's top edge where a heading counts as reached. Defaults to 96.
indentnumberHorizontal step in pixels per depth level. Defaults to 12.
titlestringText above the list and the nav label. Defaults to "On this page".
classNamestringExtra 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

components/ui/rail-toc.tsx
"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>
  )
}

On this page

Introduction

Scroll this box and watch the arrow ride the rail to the heading you are reading. Scroll back up and it turns around.

Installation

Scroll this box and watch the arrow ride the rail to the heading you are reading. Scroll back up and it turns around.

Usage

Scroll this box and watch the arrow ride the rail to the heading you are reading. Scroll back up and it turns around.

Props

Scroll this box and watch the arrow ride the rail to the heading you are reading. Scroll back up and it turns around.

FAQ

Scroll this box and watch the arrow ride the rail to the heading you are reading. Scroll back up and it turns around.

In this box