Skip to content

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.

FreeSpatial 3Dminimal
Preview

Now showing

Design

Props

No runtime dependencies
"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>
  );
}
Notes

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.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

New

Axis Flow Nav

components

A 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.

navigationnavbarvariable font
New

A 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.

navigationnavbarascii
New

A 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.

navigationnavbarunderline