Rail

A compact section navigator that expands around the pointer, previews labels without opening a menu, and stays usable on touch devices.

Items
Width
Gap
Theme
section-rail.tsxCopy
'use client';
// SectionRail — a compact, proximity-aware section navigator.
//
// Dependencies: React 18+ and Tailwind CSS. Nothing else — drop this file in,
// make sure Tailwind scans it, and import { SectionRail }.
//
// Usage:
// const items = [{ id: 'intro', label: 'Intro' }, { id: 'work', label: 'Work' }];
// const [active, setActive] = useState(items[0].id);
// <SectionRail items={items} value={active} onValueChange={setActive} />
//
// It's a controlled component: wire onValueChange to a scroll-spy or your
// router. Customize sizing via the railWidths / railGaps maps below, and the
// line colors in the two `backgroundColor` expressions near the bottom.
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
export type RailItem = {
id: string;
label: string;
};
type SectionRailProps = {
items: RailItem[];
value: string;
onValueChange: (id: string) => void;
width?: 'narrow' | 'default' | 'wide';
gap?: 'compact' | 'default' | 'spacious';
theme?: 'light' | 'dark';
className?: string;
};
const railWidths = {
narrow: { rail: 42, line: 2, widths: [42, 32, 24, 18] },
default: { rail: 52, line: 3, widths: [52, 40, 28, 20] },
wide: { rail: 62, line: 3, widths: [62, 48, 34, 24] },
} as const;
const railGaps = { compact: 22, default: 28, spacious: 34 } as const;
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
function usePrefersReducedMotion() {
return useSyncExternalStore(
(onChange) => {
const query = window.matchMedia(REDUCED_MOTION_QUERY);
query.addEventListener('change', onChange);
return () => query.removeEventListener('change', onChange);
},
() => window.matchMedia(REDUCED_MOTION_QUERY).matches,
() => false,
);
}
export function SectionRail({
items,
value,
onValueChange,
width = 'default',
gap = 'default',
theme = 'light',
className = '',
}: SectionRailProps) {
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
// The tooltip is a single persistent element that slides between rows and
// swaps its text. `shownIndex` lags one step behind so the label stays
// readable while the tooltip fades out.
const [shownIndex, setShownIndex] = useState(0);
const railRef = useRef<HTMLElement>(null);
const touchPreviewIndex = useRef<number | null>(null);
const touchShouldActivate = useRef(false);
const reduceMotion = usePrefersReducedMotion();
const metrics = railWidths[width];
const density = items.length >= 10 ? 0.5 : items.length >= 7 ? 0.8 : 1;
const rowHeight = Math.round(railGaps[gap] * density);
const dark = theme === 'dark';
// Show a row's tooltip. `shownIndex` is only ever advanced (never reset on
// leave), so the label stays put and readable while the tooltip fades out.
const showPreview = (index: number) => {
setPreviewIndex(index);
setShownIndex(index);
};
useEffect(() => {
if (previewIndex === null) return;
const dismissPreview = (event: PointerEvent) => {
if (!railRef.current?.contains(event.target as Node)) {
touchPreviewIndex.current = null;
touchShouldActivate.current = false;
setPreviewIndex(null);
}
};
document.addEventListener('pointerdown', dismissPreview);
return () => document.removeEventListener('pointerdown', dismissPreview);
}, [previewIndex]);
return (
<nav
ref={railRef}
aria-label="Page sections"
className={`relative flex flex-col items-start ${className}`}
style={{ width: metrics.rail }}
onPointerLeave={(event) => {
if (event.pointerType !== 'touch') setPreviewIndex(null);
}}
>
{/* One persistent tooltip: slides to the active row, text crossfades. */}
<span
aria-hidden="true"
className={`pointer-events-none absolute z-10 whitespace-nowrap rounded-xl border px-3 py-2 text-sm font-medium shadow-[0_10px_32px_rgba(0,0,0,0.22)] ${dark ? 'border-white/[0.12] bg-[#272727] text-white/75' : 'border-black/[0.08] bg-white text-black/70'}`}
style={{
left: metrics.rail + 12,
top: shownIndex * rowHeight + rowHeight / 2,
opacity: previewIndex === null ? 0 : 1,
transform: `translateY(-50%) translateX(${previewIndex === null ? 8 : 0}px)`,
transition: reduceMotion
? 'opacity 150ms ease'
: 'opacity 150ms ease, transform 150ms cubic-bezier(0.23,1,0.32,1), top 200ms cubic-bezier(0.23,1,0.32,1)',
}}
>
{items[shownIndex]?.label}
</span>
{items.map((item, index) => {
const active = value === item.id;
const distance = previewIndex === null ? null : Math.abs(previewIndex - index);
const width = metrics.widths[Math.min(distance ?? metrics.widths.length - 1, metrics.widths.length - 1)];
const emphasized = previewIndex === index || (previewIndex === null && active);
return (
<button
key={item.id}
type="button"
aria-current={active ? 'location' : undefined}
aria-label={item.label}
onClick={(event) => {
const touchLike = window.matchMedia('(hover: none), (pointer: coarse)').matches;
if (touchLike && event.detail > 0) {
const shouldActivate = touchShouldActivate.current;
touchShouldActivate.current = false;
if (!shouldActivate) return;
}
onValueChange(item.id);
}}
onPointerDown={(event) => {
if (event.pointerType !== 'touch') return;
const secondTap = touchPreviewIndex.current === index;
touchShouldActivate.current = secondTap;
touchPreviewIndex.current = index;
showPreview(index);
}}
onPointerEnter={(event) => {
if (event.pointerType !== 'touch') showPreview(index);
}}
onFocus={() => showPreview(index)}
onBlur={() => setPreviewIndex(null)}
className="group relative flex cursor-pointer items-center outline-none"
style={{ height: rowHeight, width: metrics.rail }}
>
<span
aria-hidden="true"
className="rounded-full transition-[width,background-color] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
style={{ width, height: metrics.line, backgroundColor: emphasized ? (dark ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.85)') : (dark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)') }}
/>
</button>
);
})}
</nav>
);
}