Curved Rail Nav
A nav whose links ride a shallow cylinder in real perspective: the active link stands forward and underlit in copper, and choosing another turns the rail toward it on a spring.
Props
"use client";
import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";
/**
* CurvedRailNav — a nav whose links ride a shallow cylinder.
*
* In real perspective, the active link stands forward, lifted and underlit
* in copper; choose another and the rail turns toward it on a spring while
* the rest recede along the curve. Hover or focus lifts a link 12px, and the
* rail leans toward the pointer. Controlled or not; folds into a menu on
* narrow containers. CSS 3D.
*
* Part of the Spatial 3D kit: a dark studio (#0a0a0d, panels #101114 and
* #16171b, ink #f2f1ee) with anodised copper #ff7a3d only as light, and
* one object in three finishes (titanium, ceramic, soft-touch polymer) under
* one rig: key, fill and rim, soft shadows, occlusion and a softbox
* reflection. Hanken Grotesk through var(--font-hanken) for display over
* Geist. Respects prefers-reduced-motion. No dependencies beyond React.
* Paste it as its own file: it repeats the kit's small helpers, which would
* clash in one module. Load Hanken Grotesk with next/font (variable:
* "--font-hanken") on a parent, or from Google Fonts or @fontsource.
* Needs Tailwind v4 (on v3.4, add the @tailwindcss/container-queries plugin).
*/
const SANS = 'var(--font-sans, "Geist", "Inter", ui-sans-serif, system-ui, sans-serif)';
const MONO = 'var(--font-mono, "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace)';
const DISPLAY = 'var(--font-hanken, "Hanken Grotesk", var(--font-sans, "Geist"), ui-sans-serif, system-ui, sans-serif)';
const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
/** The dark studio the kit is set in. */
const ROOM = {
ground: "#0a0a0d",
panel: "#101114",
raised: "#16171b",
ink: "rgba(242,241,238,0.92)",
ink2: "rgba(242,241,238,0.6)",
ink3: "rgba(242,241,238,0.38)",
line: "rgba(242,241,238,0.08)",
line2: "rgba(242,241,238,0.16)",
copper: "#ff7a3d",
} as const;
/**
* The three finishes the object can wear. `base` is the colour; the rest are
* shader parameters: gloss (0-1), specular strength, rim, subsurface wrap, and
* how metallic it is. `css` holds the colours the CSS-3D parts draw with.
*/
export const S3_FINISHES = {
titanium: {
label: "Titanium",
base: [0.725, 0.737, 0.761],
gloss: 0.62,
spec: 0.8,
rim: 0.3,
sss: 0,
metal: 1,
css: { face: "#c9ccd2", side: "#8d9098", edge: "#e6e8ec", shade: "#5d6068" },
},
ceramic: {
label: "Ceramic",
base: [0.925, 0.906, 0.867],
gloss: 0.4,
spec: 0.25,
rim: 0.18,
sss: 0.08,
metal: 0,
css: { face: "#ece7dd", side: "#c9c2b5", edge: "#faf7f1", shade: "#9a9386" },
},
polymer: {
label: "Polymer",
base: [0.106, 0.11, 0.125],
gloss: 0.3,
spec: 0.12,
rim: 0.22,
sss: 0,
metal: 0,
css: { face: "#26272c", side: "#18191d", edge: "#3a3b41", shade: "#0e0f12" },
},
} as const;
export type S3Finish = keyof typeof S3_FINISHES;
function own<T extends object>(map: T, key: string): key is Extract<keyof T, string> {
return Object.prototype.hasOwnProperty.call(map, key);
}
function finishOf(name: string) {
return own(S3_FINISHES, name) ? S3_FINISHES[name] : S3_FINISHES.titanium;
}
function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v;
}
/** Links: strip tabs and newlines, refuse backslashes, allow http(s), mailto, tel and same-site paths. */
function safeHref(raw: string): string {
const v = raw.replace(/[\t\n\r]/g, "").trim();
if (!v || v.includes("\\")) return "#";
if (/^(https?:|mailto:|tel:)/i.test(v)) return v;
if (/^[/#?]/.test(v) && !/^\/\//.test(v)) return v;
return "#";
}
function subscribeMotion(cb: () => void) {
const m = window.matchMedia("(prefers-reduced-motion: reduce)");
m.addEventListener("change", cb);
return () => m.removeEventListener("change", cb);
}
/** True when the visitor asked for reduced motion. False on the server. */
function useReducedMotion(): boolean {
return useSyncExternalStore(
subscribeMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
// Each part carries only the CSS it needs, so any one of them works on its own.
// Nothing is interpolated into these sheets; colours arrive as CSS variables.
// revert-layer keeps an element's own rounded corners when a host page's focus rule flattens them.
const FOCUS_CSS = ".s3-scope :focus-visible,.s3-scope:focus-visible{outline:2px solid var(--s3-ring,#ff7a3d);outline-offset:3px;border-radius:revert-layer}";
export type S3Link = { label: string; href: string };
// The cap's travel and its spring: down in 90ms, back with a slight overshoot in 320ms.
const KEYCAP_CSS =
".s3-key{--s3-depth:7px;transform:translateY(0);box-shadow:0 1px 0 var(--s3-side),0 2px 0 var(--s3-side),0 3px 0 var(--s3-side),0 4px 0 var(--s3-side),0 5px 0 var(--s3-side),0 6px 0 var(--s3-side),0 7px 0 var(--s3-shade),0 16px 26px -10px rgba(0,0,0,.65);transition:transform 320ms cubic-bezier(.34,1.56,.64,1),box-shadow 320ms cubic-bezier(.34,1.56,.64,1)}" +
".s3-key:hover{transform:translateY(-1px);box-shadow:0 1px 0 var(--s3-side),0 2px 0 var(--s3-side),0 3px 0 var(--s3-side),0 4px 0 var(--s3-side),0 5px 0 var(--s3-side),0 6px 0 var(--s3-side),0 7px 0 var(--s3-side),0 8px 0 var(--s3-shade),0 20px 30px -10px rgba(0,0,0,.7)}" +
".s3-key[data-down]{transform:translateY(6px);box-shadow:0 1px 0 var(--s3-shade),0 6px 12px -8px rgba(0,0,0,.6);transition-duration:90ms;transition-timing-function:ease-out}" +
".s3-key .s3-spec{background:radial-gradient(120px 70px at var(--s3-mx,30%) var(--s3-my,0%),rgba(255,255,255,.34),transparent 70%);opacity:.8;transition:opacity 220ms}" +
".s3-key:hover .s3-spec{opacity:1}" +
".s3-key .s3-led{background:var(--s3-led-off);box-shadow:none;transition:background-color 180ms,box-shadow 180ms}" +
".s3-key[data-down] .s3-led,.s3-key[data-lit] .s3-led{background:#ff7a3d;box-shadow:0 0 6px 1px rgba(255,122,61,.9),0 0 16px 2px rgba(255,122,61,.45)}" +
"@media (prefers-reduced-motion: reduce){.s3-key,.s3-key[data-down]{transition:none}}";
export type KeycapButtonProps = {
children: ReactNode;
href?: string;
onClick?: () => void;
type?: "button" | "submit";
/** "key": a raised keycap in a finish; "ghost": a hairline key for the second action. */
variant?: "key" | "ghost";
finish?: S3Finish;
size?: "lg" | "md";
/** Keep the status light on (a chosen option). */
lit?: boolean;
disabled?: boolean;
ariaLabel?: string;
ariaPressed?: boolean;
className?: string;
};
/**
* A button machined like a keycap. Its skirt is a stack of hard shadows, so
* pressing it (pointer, Enter or Space) drops the cap 6px and the skirt
* collapses in 90ms; letting go springs it back past rest in 320ms. A soft
* specular sits where the pointer is, the cap lifts 1px on hover, and a
* copper status light in the corner glows while it is held (or stays lit
* with `lit`). Finishes: titanium, ceramic, polymer. Renders a link when
* given an href, else a button.
*/
export function KeycapButton({ children, href, onClick, type = "button", variant = "key", finish = "titanium", size = "lg", lit, disabled, ariaLabel, ariaPressed, className }: KeycapButtonProps) {
const [down, setDown] = useState(false);
const f = finishOf(finish).css;
const ghost = variant === "ghost";
const dark = finish === "polymer" || ghost;
const style = {
"--s3-side": ghost ? "rgba(242,241,238,0.14)" : f.side,
"--s3-shade": ghost ? "rgba(242,241,238,0.06)" : f.shade,
"--s3-led-off": dark ? "rgba(242,241,238,0.18)" : "rgba(10,10,13,0.18)",
"--s3-ring": "#ff7a3d",
background: ghost ? "rgba(22,23,27,0.9)" : `linear-gradient(180deg, ${f.edge}, ${f.face} 38%, ${f.face} 70%, ${f.side})`,
color: dark ? "rgba(242,241,238,0.92)" : "#16171b",
border: ghost ? "1px solid rgba(242,241,238,0.16)" : `1px solid ${f.edge}`,
touchAction: "manipulation",
} as CSSProperties;
const cls =
"s3-scope s3-key relative inline-flex select-none items-center justify-center gap-3 overflow-hidden rounded-[14px] font-semibold outline-none " +
(size === "lg" ? "h-[52px] px-6 text-[15px] " : "h-11 px-5 text-[14px] ") +
(disabled ? "pointer-events-none opacity-45 " : "cursor-pointer ") +
(className ?? "");
const press = (on: boolean) => {
if (!disabled) setDown(on);
};
const handlers = {
onPointerDown: () => press(true),
onPointerUp: () => press(false),
onPointerLeave: () => press(false),
onPointerCancel: () => press(false),
onPointerMove: (e: ReactPointerEvent<HTMLElement>) => {
const r = e.currentTarget.getBoundingClientRect();
e.currentTarget.style.setProperty("--s3-mx", `${(((e.clientX - r.left) / r.width) * 100).toFixed(1)}%`);
e.currentTarget.style.setProperty("--s3-my", `${(((e.clientY - r.top) / r.height) * 100).toFixed(1)}%`);
},
onKeyDown: (e: ReactKeyboardEvent) => {
if (!e.repeat && (e.key === "Enter" || e.key === " ")) press(true);
},
onKeyUp: () => press(false),
onBlur: () => press(false),
};
const inner = (
<>
<span aria-hidden className="s3-spec pointer-events-none absolute inset-0" />
<span aria-hidden className="s3-led absolute left-2.5 top-2.5 size-[5px] rounded-full" />
<span className="relative" style={{ fontFamily: SANS, letterSpacing: "-0.005em" }}>
{children}
</span>
</>
);
const data = { "data-down": down ? "" : undefined, "data-lit": lit ? "" : undefined };
if (href) {
return (
<>
<style>{FOCUS_CSS + KEYCAP_CSS}</style>
<a href={safeHref(href)} onClick={onClick} aria-label={ariaLabel} className={cls} style={style} {...data} {...handlers}>
{inner}
</a>
</>
);
}
return (
<>
<style>{FOCUS_CSS + KEYCAP_CSS}</style>
<button type={type} onClick={onClick} disabled={disabled} aria-label={ariaLabel} aria-pressed={ariaPressed} className={cls} style={style} {...data} {...handlers}>
{inner}
</button>
</>
);
}
const RAIL_STEP = 8.5;
const RAIL_SLOT = 132;
const RAIL_CSS =
".s3-rail{transition:transform 560ms cubic-bezier(.34,1.3,.64,1)}" +
".s3-rail-item{transition:transform 420ms cubic-bezier(.34,1.3,.64,1),color 220ms,opacity 220ms}" +
".s3-rail-glow{transition:opacity 260ms,transform 420ms cubic-bezier(.34,1.3,.64,1)}" +
"@keyframes s3-drop{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}" +
"@media (prefers-reduced-motion: reduce){.s3-rail,.s3-rail-item,.s3-rail-glow{transition:none!important}.s3-drop{animation:none!important}}";
export type CurvedRailNavProps = {
brand: string;
links: S3Link[];
cta?: S3Link;
/** The active link's href, when the page controls it (a scroll spy). */
active?: string;
defaultActive?: string;
onNavigate?: (href: string) => void;
className?: string;
};
/**
* A nav whose links ride a shallow cylinder in real perspective. The active
* link stands forward, lifted and underlit in copper; choose another and the
* whole rail turns toward it (560ms, a spring with a slight overshoot) while
* the rest recede along the curve, never so far that they stop reading. Hovering or focusing a link lifts it 12px toward you, and the rail
* leans a few degrees toward the pointer. Controlled (a scroll spy) or not;
* on a narrow container the links fold into a menu. The call to action is the
* kit's keycap.
*/
export function CurvedRailNav({ brand, links, cta, active, defaultActive, onNavigate, className }: CurvedRailNavProps) {
const [chosen, setChosen] = useState(defaultActive ?? links[0]?.href ?? "");
const [open, setOpen] = useState(false);
const [lean, setLean] = useState(0);
const [hover, setHover] = useState(-1);
const current = active ?? chosen;
const on = Math.max(0, links.findIndex((l) => l.href === current));
const menuRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const panelId = useId();
const reduced = useReducedMotion();
const railRef = useRef<HTMLDivElement>(null);
const [room, setRoom] = useState(0);
useEffect(() => {
const el = railRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setRoom(el.offsetWidth));
ro.observe(el);
return () => ro.disconnect();
}, []);
// Turning the rail toward an end link also slides the row sideways (by about 0.4 of a slot per link from the middle), so budget for it.
const slot = room ? clamp(Math.floor(room / (links.length + (links.length - 1) * 0.4)), 80, RAIL_SLOT) : RAIL_SLOT;
const R = slot / (2 * Math.tan((RAIL_STEP * Math.PI) / 360));
const mid = (links.length - 1) / 2;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
toggleRef.current?.focus();
}
};
const onDown = (e: PointerEvent) => {
const n = e.target as Node;
if (!menuRef.current?.contains(n) && !toggleRef.current?.contains(n)) setOpen(false);
};
document.addEventListener("keydown", onKey);
document.addEventListener("pointerdown", onDown);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onDown);
};
}, [open]);
const go = (href: string) => {
setChosen(href);
setOpen(false);
onNavigate?.(href);
};
const click = (e: { preventDefault: () => void }, href: string) => {
if (href.startsWith("#") && onNavigate) e.preventDefault();
go(href);
};
return (
<header className={"s3-scope @container relative w-full " + (className ?? "")} style={{ background: "rgba(10,10,13,0.82)", color: ROOM.ink, backdropFilter: "blur(14px) saturate(120%)", "--s3-ring": ROOM.copper } as CSSProperties}>
<style>{FOCUS_CSS + RAIL_CSS}</style>
<nav aria-label="Main" className="flex h-16 items-center gap-6 border-b px-5 @3xl:h-[76px] @3xl:px-8" style={{ borderColor: ROOM.line }}>
<a
href="#"
className="flex shrink-0 items-center gap-2 text-[20px] font-bold tracking-[-0.02em]"
style={{ fontFamily: DISPLAY, color: "#f2f1ee" }}
onClick={(e) => {
if (!onNavigate) return;
e.preventDefault();
onNavigate("#top");
}}
>
<span aria-hidden className="size-2 rounded-full" style={{ background: ROOM.copper, boxShadow: "0 0 10px rgba(255,122,61,0.8)" }} />
{brand}
</a>
<div
ref={railRef}
className="relative mx-auto hidden h-full min-w-0 flex-1 overflow-hidden @3xl:block"
style={{ perspective: 900 }}
onPointerMove={(e) => {
const r = e.currentTarget.getBoundingClientRect();
if (!reduced) setLean(((e.clientX - r.left) / r.width - 0.5) * 6);
}}
onPointerLeave={() => setLean(0)}
>
<ul className="s3-rail absolute left-1/2 top-1/2 m-0 h-0 w-0 list-none p-0" style={{ transformStyle: "preserve-3d", transform: `translateZ(${(-R).toFixed(1)}px) rotateY(${(-(on - mid) * RAIL_STEP * 0.4 + lean).toFixed(2)}deg)` }}>
{links.map((l, i) => {
const isOn = i === on;
const lift = isOn ? 26 : i === hover ? 12 : 0;
return (
<li key={l.href} className="absolute" style={{ transformStyle: "preserve-3d", transform: `rotateY(${((i - mid) * RAIL_STEP).toFixed(2)}deg) translateZ(${R.toFixed(1)}px)` }}>
<a
href={safeHref(l.href)}
aria-current={isOn ? "page" : undefined}
onClick={(e) => click(e, l.href)}
onPointerEnter={() => setHover(i)}
onPointerLeave={() => setHover(-1)}
onFocus={() => setHover(i)}
onBlur={() => setHover(-1)}
className="s3-rail-item absolute flex items-center justify-center whitespace-nowrap rounded-[14px] px-3 py-2.5 text-[14px]"
style={{
width: slot - 8,
transform: `translate(-50%, -50%) translateZ(${lift}px)`,
color: isOn ? "#f2f1ee" : ROOM.ink2,
fontFamily: SANS,
fontWeight: isOn ? 600 : 500,
background: isOn ? "linear-gradient(180deg, rgba(242,241,238,0.08), rgba(242,241,238,0.02))" : "transparent",
boxShadow: isOn ? "inset 0 0 0 1px rgba(242,241,238,0.14)" : "none",
}}
>
{l.label}
<span aria-hidden className="s3-rail-glow absolute inset-x-5 -bottom-px h-px" style={{ opacity: isOn ? 1 : 0, background: ROOM.copper, boxShadow: "0 0 10px 1px rgba(255,122,61,0.7)" }} />
</a>
</li>
);
})}
</ul>
</div>
<div className="ml-auto flex items-center gap-3 @3xl:ml-0">
{cta ? (
<span className="hidden @xl:inline-flex">
<KeycapButton href={cta.href} size="md">
{cta.label}
</KeycapButton>
</span>
) : null}
<button
ref={toggleRef}
type="button"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((v) => !v)}
className="inline-flex h-11 items-center rounded-[14px] border px-4 text-[12px] uppercase tracking-[0.14em] @3xl:hidden"
style={{ fontFamily: MONO, borderColor: ROOM.line2 }}
>
{open ? "Close" : "Menu"}
</button>
</div>
</nav>
<div ref={menuRef} id={panelId} hidden={!open} className="s3-drop absolute inset-x-0 top-full z-20 border-b px-5 pb-6 pt-3 @3xl:hidden" style={{ background: ROOM.ground, borderColor: ROOM.line, animation: `s3-drop 240ms ${EASE}` }}>
<ul className="m-0 list-none p-0">
{links.map((l, i) => (
<li key={l.href} className="border-b last:border-b-0" style={{ borderColor: ROOM.line }}>
<a href={safeHref(l.href)} aria-current={i === on ? "page" : undefined} onClick={(e) => click(e, l.href)} className="flex items-center gap-3 py-3.5 text-[22px] font-semibold tracking-[-0.02em]" style={{ fontFamily: DISPLAY, color: i === on ? "#f2f1ee" : ROOM.ink2 }}>
<span aria-hidden className="size-1.5 rounded-full" style={{ background: i === on ? ROOM.copper : "transparent" }} />
{l.label}
</a>
</li>
))}
{cta ? (
<li className="pt-4 @xl:hidden">
<KeycapButton href={cta.href} size="md">
{cta.label}
</KeycapButton>
</li>
) : null}
</ul>
</div>
</header>
);
}
About this component
A navigation bar with depth. The links ride a shallow cylinder in real perspective, and the active one stands forward, lifted 26px toward you with a copper underglow. Choose another and the whole rail turns toward it on a spring with a slight overshoot, the other links receding and foreshortening along the curve but never so far that they stop reading. Hovering or focusing a link lifts it 12px, and the rail leans a few degrees toward the pointer. The active link can be controlled (the kit's site drives it from a scroll spy), and on a narrow container the links fold into a menu. The call to action is the kit's keycap.
Curator’s note
The Spatial 3D kit's navigation (slot 5), cut from spatial-3d-site, where a scroll spy drives it. Free: CSS 3D transforms on a preserve-3d rail.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Axis Flow Nav
componentsA nav whose links are one flush line: the active link is loud, the rest condensed, and choosing another makes the width flow to it letter by letter while the nav never changes size.
ASCII Density Nav
componentsA nav whose active link is marked by a band of ASCII density: characters thicken through a ramp under the active link and flow to the next one on a spring, its peak in signal.
Hairline Morph Nav
componentsA nav whose single hairline stretches toward the next link and settles under it: its leading edge runs ahead on a quick spring while its trailing edge follows on a slower one, and the active label steps up a weight without shifting.