rail
Left-edge section minimap: one tick per heading, active-section highlight, labels on hover. Hidden below 1280px.
navigationscrollspytoc
Preview
First section
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Second section
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Third section
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Widen the window past 1280px and scroll — the tick on the left edge follows the section you are reading, and hovering the rail reveals the labels.
Installation
shadcn CLI
npx shadcn@latest add https://mocoui.site/r/rail.jsonManual
Copy each file from the Source section into its target path:
registry/rail/rail.tsx → components/moco/rail.tsx
registry/rail/rail.css → components/moco/rail.cssUsage
"use client";
import { Rail, type Heading } from "./rail";
const HEADINGS: Heading[] = [
{ id: "one", text: "First section" },
{ id: "two", text: "Second section" },
{ id: "three", text: "Third section" },
];
export default function RailDemo() {
return (
<div>
<Rail headings={HEADINGS} />
<main style={{ maxWidth: "42rem", margin: "0 auto", padding: "4rem 1rem" }}>
{HEADINGS.map((h) => (
<section key={h.id}>
<h2 id={h.id}>{h.text}</h2>
{Array.from({ length: 12 }, (_, i) => (
<p key={i}>
Widen the window past 1280px and scroll — the tick on the left edge follows the
section you are reading, and hovering the rail reveals the labels.
</p>
))}
</section>
))}
</main>
</div>
);
}
Source
components/moco/rail.tsx
"use client";
import * as React from "react";
import "./rail.css";
import { useScrollspy, smoothScrollTo } from "@/components/moco/hooks";
export interface Heading {
id: string;
text: string;
level?: number;
}
export interface RailProps {
headings: Heading[];
}
/** Left-edge minimap: one tick per H2. Hidden below 1280px. */
export function Rail({ headings }: RailProps) {
const ids = React.useMemo(() => headings.map((h) => h.id), [headings]);
const active = useScrollspy(ids);
if (!headings.length) return null;
return (
<nav className="moco-rail" aria-label="Sections">
{headings.map((h, i) => (
<button
key={h.id}
type="button"
className="moco-rail-item"
aria-current={i === active}
aria-label={`Jump to ${h.text}`}
onClick={() => smoothScrollTo(h.id)}
>
<span className="moco-rail-tick" aria-hidden="true" />
<span className="moco-rail-label" aria-hidden="true">
{h.text}
</span>
</button>
))}
</nav>
);
}
components/moco/rail.css
.moco-rail {
position: fixed;
left: 28px;
top: 50%;
transform: translateY(-50%);
z-index: 40;
display: flex;
flex-direction: column;
gap: 12px;
padding: 0.5rem 0;
}
.moco-rail-item {
display: flex;
align-items: center;
gap: 0.7rem;
height: 14px;
/* Button reset the host app may not provide. */
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
cursor: pointer;
}
.moco-rail-tick {
display: block;
height: 1px;
width: 18px;
background: var(--moco-muted);
opacity: 0.4;
flex: none;
transition:
width 300ms var(--moco-ease),
opacity 300ms var(--moco-ease),
background 300ms var(--moco-ease);
}
.moco-rail-item[aria-current="true"] .moco-rail-tick {
width: 30px;
opacity: 1;
height: 2px;
background: var(--moco-accent-dk);
}
.moco-rail-label {
font-size: 12px;
color: var(--moco-faint);
white-space: nowrap;
opacity: 0;
transform: translateX(-6px);
transition:
opacity 250ms var(--moco-ease),
transform 250ms var(--moco-ease);
pointer-events: none;
}
.moco-rail:hover .moco-rail-label,
.moco-rail:focus-within .moco-rail-label {
opacity: 1;
transform: none;
}
.moco-rail-item[aria-current="true"] .moco-rail-label {
color: var(--moco-ink);
}
@media (max-width: 1279px) {
.moco-rail {
display: none;
}
}