Navigation
SubwayLineNav
Your pages as stations on a transit map, with 45 degree bends and an interchange. A three-car train runs to the stop you press and lights the track behind it; hover a stop and the board says how many stops away.
Download
- React
- Tailwind CSS v4
- motion
components/ui/subway-line-nav.tsx
"use client";
import { useRef, useState, type MouseEvent } from "react";
import { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, type AnimationPlaybackControls } from "motion/react";
type Stop = {
label: string;
href: string;
/** Name of a second line crossing here. Draws an interchange. */
transfer?: string;
};
type SubwayLineNavProps = {
/** Pages as stations along the line, 2 to 7. Short names fit best. */
stops: Stop[];
/** Index of the station you are at first. */
start?: number;
/** Name on the board. */
line?: string;
/** Line colour. */
color?: string;
/** Colour of a crossing line. */
transferColor?: string;
/** Minutes per stop shown on the board. */
minutesPerStop?: number;
/** Called on press. Call event.preventDefault() to stay on the page. */
onSelect?: (stop: Stop, event: MouseEvent<Element>) => void;
/** Name read out by screen readers. */
label?: string;
className?: string;
};
// Bends at 45 degrees only, like a transit map. It falls left to right so the names, which rise, stay clear of it. The ends are spare track for the carriages.
const ROUTE: [number, number][] = [
[36, 124],
[190, 124],
[290, 224],
[410, 224],
[490, 304],
[596, 304],
];
const LEGS = ROUTE.slice(1).map((p, i) => Math.hypot(p[0] - ROUTE[i][0], p[1] - ROUTE[i][1]));
const TOTAL = LEGS.reduce((a, b) => a + b, 0);
const SPARE = 34;
const SPAN = TOTAL - SPARE * 2;
const CARRIAGE = 33;
const POINTS = ROUTE.map((p) => p.join(",")).join(" ");
const place = (distance: number) => {
let left = distance;
for (let i = 0; i < LEGS.length; i++) {
if (left <= LEGS[i] || i === LEGS.length - 1) {
const [x0, y0] = ROUTE[i];
const [x1, y1] = ROUTE[i + 1];
const r = left / LEGS[i];
return { x: x0 + (x1 - x0) * r, y: y0 + (y1 - y0) * r, angle: (Math.atan2(y1 - y0, x1 - x0) * 180) / Math.PI };
}
left -= LEGS[i];
}
return { x: 0, y: 0, angle: 0 };
};
const carriage = (distance: number) => {
const p = place(distance);
return `translate(${p.x.toFixed(1)} ${p.y.toFixed(1)}) rotate(${p.angle.toFixed(1)}) translate(0 19)`;
};
/* SubwayLineNav: your pages as stations on a transit map. A train runs the line to the stop you press, lighting the track behind it, and the board tells how far each stop is. */
export function SubwayLineNav({
stops,
start = 0,
line = "Green Line",
color = "#16895b",
transferColor = "#d9722b",
minutesPerStop = 2,
onSelect,
label = "Site map",
className = "",
}: SubwayLineNavProps) {
const reduce = useReducedMotion();
const gap = SPAN / Math.max(stops.length - 1, 1);
const stopAt = (i: number) => SPARE + gap * i;
const train = useMotionValue(stopAt(start));
const run = useRef<AnimationPlaybackControls | null>(null);
const way = useRef(1);
const cars = useRef<(SVGGElement | null)[]>([]);
const [goal, setGoal] = useState(start);
const [reached, setReached] = useState(start);
const [next, setNext] = useState<number | null>(null);
const [peek, setPeek] = useState<number | null>(null);
const lit = useTransform(train, (s) => `${s - SPARE} 9999`);
useMotionValueEvent(train, "change", (s) => {
cars.current.forEach((car, k) => car?.setAttribute("transform", carriage(s + (1 - k) * CARRIAGE * way.current)));
const spot = (s - SPARE) / gap;
setReached(Math.floor(spot + 0.01));
if (run.current) setNext(way.current > 0 ? Math.floor(spot + 0.01) + 1 : Math.ceil(spot - 0.01) - 1);
});
const go = (i: number) => {
const from = train.get();
const to = stopAt(i);
setGoal(i);
run.current?.stop();
if (reduce || from === to) {
train.set(to);
return;
}
way.current = Math.sign(to - from);
run.current = animate(train, to, {
duration: 0.5 + (Math.abs(to - from) / gap) * 0.42,
ease: "easeInOut",
onComplete: () => {
run.current = null;
setNext(null);
},
});
};
const hops = peek === null ? 0 : Math.abs(peek - goal);
const board =
next !== null && stops[next]
? { top: "Next stop", main: stops[next].label }
: peek !== null && hops
? { top: `${hops} ${hops === 1 ? "stop" : "stops"}, ${hops * minutesPerStop} min`, main: stops[peek].label }
: { top: "You are here", main: stops[goal].label };
return (
<nav aria-label={label} className={`w-full max-w-[720px] ${className}`}>
<svg viewBox="0 0 640 400" className="block h-auto w-full rounded-[1.5%] bg-[#f4efe2] select-none">
<path d="M640 92 L470 92 L400 150 L300 150 L190 262 L110 262 L0 372 L0 312 L90 222 L170 222 L280 110 L380 110 L450 50 L640 50 Z" fill="#cfe2e6" />
{stops.map((s, i) => {
if (!s.transfer) return null;
const p = place(stopAt(i));
return (
<g key={s.href}>
<line x1={p.x} y1="0" x2={p.x} y2="400" stroke={transferColor} strokeWidth="9" />
<text x={p.x + 9} y="24" fontSize="14" fontWeight="700" fill={transferColor} className="font-sans">
{s.transfer}
</text>
</g>
);
})}
<polyline points={POINTS} fill="none" stroke={color} strokeWidth="11" strokeLinejoin="round" opacity="0.28" />
<motion.polyline
points={POINTS}
fill="none"
stroke={color}
strokeWidth="11"
strokeLinejoin="round"
style={{ strokeDasharray: lit, strokeDashoffset: -SPARE }}
/>
{[0, 1, 2].map((k) => (
<g
key={k}
ref={(el) => {
cars.current[k] = el;
}}
transform={carriage(stopAt(start) + (1 - k) * CARRIAGE)}
>
<rect x="-15" y="-8" width="30" height="16" rx={k === 0 ? 6 : 3} fill="#23211c" />
<rect x="-11" y="-4" width="9" height="6" fill="#ffdf8f" />
<rect x="2" y="-4" width="9" height="6" fill="#ffdf8f" />
<rect x="-15" y="4" width="30" height="2" fill={color} />
</g>
))}
{stops.map((s, i) => {
const p = place(stopAt(i));
const passed = i <= reached;
return (
<a
key={s.href}
href={s.href}
aria-current={i === goal ? "page" : undefined}
aria-label={s.transfer ? `${s.label}, change for ${s.transfer}` : s.label}
className="group outline-none"
onClick={(e) => {
onSelect?.(s, e);
go(i);
}}
onPointerEnter={() => setPeek(i)}
onPointerLeave={() => setPeek(null)}
onFocus={() => setPeek(i)}
onBlur={() => setPeek(null)}
>
<circle cx={p.x} cy={p.y} r="24" fill="transparent" />
<circle cx={p.x} cy={p.y} r="20" fill="none" stroke="#23211c" strokeWidth="2" strokeDasharray="4 3" className="opacity-0 group-focus-visible:opacity-100" />
<circle
cx={p.x}
cy={p.y}
r={s.transfer ? 14 : 10.5}
fill={i === goal ? color : "#fff"}
stroke={s.transfer ? "#23211c" : passed ? color : "#8fb3a2"}
strokeWidth="4"
className="transition-colors group-hover:fill-[#ffdf8f]"
/>
<text
x={p.x + 12}
y={p.y - 20}
transform={`rotate(-34 ${p.x + 12} ${p.y - 20})`}
fontSize="19"
fontWeight={i === goal ? 800 : 600}
fill="#23211c"
className="font-sans"
>
{s.label}
</text>
</a>
);
})}
<g className="pointer-events-none">
<rect x="14" y="316" width="242" height="70" rx="6" fill="#16140f" />
<rect x="14" y="316" width="9" height="70" rx="3" fill={color} />
<text x="36" y="340" fontSize="13" fill="#a79f8c" className="font-mono">
{`${line} · ${board.top}`}
</text>
<text x="36" y="370" fontSize="24" fontWeight="700" fill="#ffc861" className="font-mono">
{board.main}
</text>
</g>
</svg>
<p aria-live="polite" className="sr-only">
{next === null ? `At ${stops[goal].label}` : ""}
</p>
</nav>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/subway-line-nav.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/subway-line-nav-demo.tsx
"use client"; import { SubwayLineNav } from "@/components/ui/subway-line-nav"; const STOPS = [ { label: "Home", href: "#home" }, { label: "Product", href: "#product" }, { label: "Pricing", href: "#pricing", transfer: "Docs Line" }, { label: "Stories", href: "#stories" }, { label: "About", href: "#about" }, { label: "Contact", href: "#contact" }, ]; export default function SubwayLineNavExample() { return ( <div className="grid h-full place-items-center overflow-hidden bg-[#22312b] p-2"> <SubwayLineNav stops={STOPS} start={1} onSelect={(_, e) => e.preventDefault()} /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| stops | { label, href, transfer? }[] | — | Pages along the line, 2 to 7. transfer = name of a crossing line. |
| start | number | 0 | Index of the station you are at first. |
| line | string | "Green Line" | Name on the board. |
| color | string | "#16895b" | Line colour. |
| transferColor | string | "#d9722b" | Colour of a crossing line. |
| minutesPerStop | number | 2 | Minutes per stop shown on the board. |
| onSelect | (stop, event) => void | — | Called on press. Call event.preventDefault() to stay on the page. |
| label | string | "Site map" | Name read out by screen readers. |
| className | string | "" | Size. Everything inside scales with the width. |