Navigation
ElevatorNav
A brass lift panel for your pages. Press a floor: the steel doors shut, the needle sweeps the arc floor by floor, and the doors open on that floor's name. A press during the trip changes the goal.
Download
- React
- Tailwind CSS v4
- motion
components/ui/elevator-nav.tsx
"use client";
import { useEffect, useState, type MouseEvent } from "react";
import { motion, useReducedMotion } from "motion/react";
type Floor = {
label: string;
href: string;
/** Small line shown in the lobby of that floor. */
note?: string;
/** Text on the button. Default: G for the first floor, then 1, 2, 3. */
mark?: string;
};
type ElevatorNavProps = {
/** Lowest floor first, 2 to 6. */
floors: Floor[];
/** Index of the floor the lift starts on. */
start?: number;
/** Seconds to travel one floor. */
secondsPerFloor?: number;
/** Called on press. Call event.preventDefault() to stay on the page. */
onSelect?: (floor: Floor, event: MouseEvent<HTMLAnchorElement>) => void;
/** Name read out by screen readers. */
label?: string;
className?: string;
};
const LOBBY = ["#7c2f25", "#27584f", "#3a4a78", "#7a5a1e", "#5b3560", "#2f5a7a"];
/* ElevatorNav: a brass lift panel. Press a floor: the doors shut, the needle sweeps over the arc floor by floor, the doors open on that page. */
export function ElevatorNav({ floors, start = 0, secondsPerFloor = 0.7, onSelect, label = "Site floors", className = "" }: ElevatorNavProps) {
const reduce = useReducedMotion();
const [at, setAt] = useState(start);
const [goal, setGoal] = useState(start);
const [open, setOpen] = useState(true);
useEffect(() => {
const after = (seconds: number, run: () => void) => {
const timer = setTimeout(run, reduce ? 0 : seconds * 1000);
return () => clearTimeout(timer);
};
if (at !== goal) {
if (open) return after(0.55, () => setOpen(false));
return after(secondsPerFloor, () => setAt((a) => a + Math.sign(goal - a)));
}
if (!open) return after(0.3, () => setOpen(true));
}, [at, goal, open, reduce, secondsPerFloor]);
const top = Math.max(floors.length - 1, 1);
const tilt = (i: number) => -68 + (136 * i) / top;
const mark = (i: number) => floors[i].mark ?? (i === 0 ? "G" : String(i));
const here = floors[at];
const going = Math.sign(goal - at);
return (
<nav aria-label={label} className={`@container w-full max-w-[640px] ${className}`}>
<div className="relative flex aspect-[3/2] gap-[0.7em] rounded-[0.5em] bg-[#20261f] bg-[repeating-linear-gradient(90deg,rgba(255,255,255,0.025)_0_0.1em,transparent_0.1em_3.6em)] p-[0.8em] text-[2.8cqw] leading-tight shadow-[inset_0_0_2em_rgba(0,0,0,0.6)]">
<div className="flex min-w-0 flex-1 flex-col items-center gap-[0.4em]">
{/* Floor arc over the doors */}
<div className="relative aspect-[2/1] w-[10.4em] shrink-0 rounded-t-full border-[0.14em] border-b-0 border-[#7d6026] bg-[radial-gradient(circle_at_50%_100%,#f4dc9c,#c9a24c_72%,#8f6d2a)] shadow-[0_0.1em_0.4em_rgba(0,0,0,0.6)]">
{floors.map((_, i) => {
const a = (tilt(i) * Math.PI) / 180;
return (
<span
key={i}
style={{ left: `${50 + 40 * Math.sin(a)}%`, bottom: `${80 * Math.cos(a) - 4}%` }}
className={`absolute -translate-x-1/2 font-serif text-[0.86em] font-bold lining-nums ${i === at ? "text-[#8c1d10]" : "text-[#4a3812]"}`}
>
{mark(i)}
</span>
);
})}
<motion.span
aria-hidden
initial={false}
animate={{ rotate: tilt(at) }}
transition={reduce ? { duration: 0 } : { duration: secondsPerFloor, ease: "linear" }}
style={{ transformOrigin: "50% 100%" }}
className="absolute bottom-0 left-1/2 -ml-[0.07em] h-[62%] w-[0.14em] rounded-t-full bg-[#2a1c08]"
/>
<span className="absolute -bottom-[0.32em] left-1/2 size-[0.65em] -translate-x-1/2 rounded-full bg-[#2a1c08]" />
</div>
{/* Doors */}
<div className="relative min-h-0 w-full flex-1 overflow-hidden border-[0.32em] border-[#a98434] bg-black shadow-[inset_0_0_0_0.08em_#5f4716,0_0_0_0.08em_#5f4716]">
<div style={{ backgroundColor: LOBBY[at % LOBBY.length] }} className="absolute inset-0 grid place-items-center bg-[radial-gradient(ellipse_at_50%_-10%,rgba(255,226,160,0.6),transparent_62%)] px-[3.4em] pb-[1.6em] text-center text-[#fbecc8]">
<span aria-hidden className="absolute inset-x-0 bottom-0 h-[1.9em] bg-[conic-gradient(#1b1712_25%,#d8cfbb_0_50%,#1b1712_0_75%,#d8cfbb_0)] bg-[size:1.3em_1.3em] shadow-[inset_0_0.5em_0.5em_rgba(0,0,0,0.5)]" />
<div>
<p className="font-serif text-[1.65em] font-bold">{here.label}</p>
{here.note && <p className="mt-[0.25em] text-[0.8em] opacity-85">{here.note}</p>}
</div>
</div>
{[-1, 1].map((side) => (
<motion.span
key={side}
aria-hidden
initial={false}
animate={{ x: open ? `${side * 74}%` : 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.55, ease: "easeInOut" }}
className={`absolute inset-y-0 w-1/2 bg-[linear-gradient(90deg,#8c9196,#d5d9db_40%,#a3a8ac_75%,#777c81),repeating-linear-gradient(90deg,rgba(255,255,255,0.08)_0_0.06em,transparent_0.06em_0.16em)] ${
side < 0 ? "left-0 border-r border-black/50" : "right-0 border-l border-black/50"
}`}
/>
))}
</div>
</div>
{/* Button plate */}
<div className="flex w-[9.6em] shrink-0 flex-col gap-[0.5em] rounded-[0.3em] border-[0.1em] border-[#6e5420] bg-[linear-gradient(135deg,#e2c777,#b6913f_45%,#d9bb68_70%,#94722d)] p-[0.6em] shadow-[0_0.15em_0.5em_rgba(0,0,0,0.7)]">
<div aria-hidden className="flex justify-center gap-[0.8em] rounded-[0.2em] bg-[#20150a] py-[0.2em] text-[0.8em]">
<span className={going > 0 ? "text-[#ffb13d]" : "text-[#4c3a1e]"}>▲</span>
<span className={going < 0 ? "text-[#ffb13d]" : "text-[#4c3a1e]"}>▼</span>
</div>
<ul className="flex flex-1 flex-col-reverse justify-around">
{floors.map((f, i) => {
const lit = i === goal;
return (
<li key={f.href}>
<a
href={f.href}
aria-current={lit ? "page" : undefined}
onClick={(e) => {
onSelect?.(f, e);
setGoal(i);
}}
className="group flex items-center gap-[0.45em] rounded-[0.3em] outline-offset-1 outline-[#20150a] focus-visible:outline-2"
>
<span
className={`grid size-[2em] shrink-0 place-items-center rounded-full border-[0.14em] border-[#6e5420] font-serif text-[0.95em] font-bold lining-nums shadow-[inset_0_0.1em_0.2em_rgba(255,255,255,0.45),0_0.08em_0.15em_rgba(0,0,0,0.5)] transition-colors group-active:translate-y-[0.06em] ${
lit ? "bg-[#ffd780] text-[#7a2b05] shadow-[0_0_0.7em_#ffb13d]" : "bg-[#efe6cf] text-[#3a2c10] group-hover:bg-white"
}`}
>
{mark(i)}
</span>
<span className="min-w-0 truncate text-[0.92em] font-semibold text-[#2c2008]">{f.label}</span>
</a>
</li>
);
})}
</ul>
</div>
<p aria-live="polite" className="sr-only">
{open && at === goal ? `${here.label} floor` : ""}
</p>
</div>
</nav>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/elevator-nav.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/elevator-nav-demo.tsx
"use client"; import { ElevatorNav } from "@/components/ui/elevator-nav"; const FLOORS = [ { label: "Lobby", href: "#lobby", note: "Start here" }, { label: "Work", href: "#work", note: "Case studies" }, { label: "Studio", href: "#studio", note: "Who we are" }, { label: "Pricing", href: "#pricing", note: "Plans from ₹499" }, { label: "Roof", href: "#roof", note: "Say hello" }, ]; export default function ElevatorNavExample() { return ( <div className="grid h-full place-items-center overflow-hidden bg-[#141a15] p-2"> <ElevatorNav floors={FLOORS} onSelect={(_, e) => e.preventDefault()} /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| floors | { label, href, note?, mark? }[] | — | Lowest floor first, 2 to 6. mark = text on the button, default G, 1, 2. |
| start | number | 0 | Index of the floor the lift starts on. |
| secondsPerFloor | number | 0.7 | Seconds to travel one floor. |
| onSelect | (floor, event) => void | — | Called on press. Call event.preventDefault() to stay on the page. |
| label | string | "Site floors" | Name read out by screen readers. |
| className | string | "" | Size. Everything inside scales with the width. |