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.tsxCopyCopy
'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> );}