Navigation
RadioDialNav
A valve radio in a walnut cabinet. Pages are stations on the amber glass. Turn the knob and the red needle sweeps through static until the green eye closes on a station; then press its name to go.
Download
- React
- Tailwind CSS v4
- motion
components/ui/radio-dial-nav.tsx
"use client";
import { useRef, useState, type KeyboardEvent, type MouseEvent, type PointerEvent } from "react";
import { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, type AnimationPlaybackControls } from "motion/react";
type Station = {
label: string;
href: string;
};
type RadioDialNavProps = {
/** Pages as stations, left to right, 2 to 6. */
stations: Station[];
/** Index of the station tuned at the start. */
start?: number;
/** Called when a station link is pressed. Call event.preventDefault() to stay on the page. */
onSelect?: (station: Station, event: MouseEvent<HTMLAnchorElement>) => void;
/** Name read out by screen readers. */
label?: string;
className?: string;
};
const SCALE = [54, 70, 88, 108, 130, 160];
const REACH = 0.05;
const TICKS = Array.from({ length: 51 }, (_, i) => `M${i * 2} ${i % 5 ? 5 : 0}V10`).join("") + "M0 10H100";
const NOISE =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence baseFrequency='1.1' numOctaves='2'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E\")";
const spot = (i: number, n: number) => (n < 2 ? 0.5 : 0.1 + (0.8 * i) / (n - 1));
/* RadioDialNav: a valve radio. Turn the knob or press a name: the red needle sweeps the amber glass through static, and the green eye closes on a station. */
export function RadioDialNav({ stations, start = 0, onSelect, label = "Site stations", className = "" }: RadioDialNavProps) {
const reduce = useReducedMotion();
const count = stations.length;
const pos = useMotionValue(spot(start, count));
const [near, setNear] = useState(start);
const run = useRef<AnimationPlaybackControls | null>(null);
const grip = useRef<number | null>(null);
const knob = useRef<HTMLDivElement>(null);
const closest = (p: number) => {
let best = 0;
stations.forEach((_, i) => {
if (Math.abs(spot(i, count) - p) < Math.abs(spot(best, count) - p)) best = i;
});
return best;
};
useMotionValueEvent(pos, "change", (p) => {
const i = closest(p);
setNear(Math.abs(spot(i, count) - p) < REACH * 0.7 ? i : -1);
});
const signal = useTransform(pos, (p) => {
const gap = Math.min(...stations.map((_, i) => Math.abs(spot(i, count) - p)));
return Math.max(0, 1 - gap / REACH);
});
const glow = useTransform(signal, [0, 1], [0.25, 1]);
const needle = useTransform(pos, (p) => `${p * 100}%`);
const turn = useTransform(pos, (p) => p * 540 - 270);
const eye = useTransform(signal, (s) => {
const open = 62 - s * 56;
return `conic-gradient(from ${-open}deg, #062414 0 ${open * 2}deg, #63f2a2 ${open * 2}deg 360deg)`;
});
const tune = (i: number) => {
run.current?.stop();
if (reduce) pos.set(spot(i, count));
else run.current = animate(pos, spot(i, count), { type: "spring", stiffness: 62, damping: 11 });
};
const angle = (e: PointerEvent) => {
const box = knob.current!.getBoundingClientRect();
return (Math.atan2(e.clientY - box.top - box.height / 2, e.clientX - box.left - box.width / 2) * 180) / Math.PI;
};
const grab = (e: PointerEvent<HTMLDivElement>) => {
run.current?.stop();
e.currentTarget.setPointerCapture(e.pointerId);
grip.current = angle(e);
};
const drag = (e: PointerEvent) => {
if (grip.current === null) return;
const now = angle(e);
const moved = ((now - grip.current + 540) % 360) - 180;
grip.current = now;
pos.set(Math.min(1, Math.max(0, pos.get() + moved / 540)));
};
const drop = () => {
if (grip.current === null) return;
grip.current = null;
tune(closest(pos.get()));
};
const key = (e: KeyboardEvent) => {
const step = e.key === "ArrowRight" || e.key === "ArrowUp" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowDown" ? -1 : 0;
if (!step) return;
e.preventDefault();
tune(Math.min(count - 1, Math.max(0, closest(pos.get()) + step)));
};
const tuned = near >= 0 ? stations[near] : null;
return (
<nav aria-label={label} className={`@container w-full max-w-[640px] ${className}`}>
<div className="relative flex aspect-[8/5] flex-col gap-[0.7em] rounded-[1.6em] bg-[#4a2711] bg-[linear-gradient(100deg,rgba(0,0,0,0.25),rgba(255,190,120,0.12)_40%,rgba(0,0,0,0.3)),repeating-linear-gradient(94deg,rgba(0,0,0,0.16)_0_0.12em,transparent_0.12em_0.5em,rgba(255,200,140,0.05)_0.5em_0.7em,transparent_0.7em_1.3em)] p-[1em] text-[2.5cqw] leading-tight shadow-[inset_0_0.15em_0.1em_rgba(255,220,170,0.25),inset_0_-0.3em_0.5em_rgba(0,0,0,0.6),0_0.5em_1.2em_rgba(0,0,0,0.5)]">
{/* Dial glass */}
<div className="relative h-[10.9em] shrink-0 overflow-hidden rounded-[0.6em] border-[0.2em] border-[#c6a35c] bg-[#3a2206] shadow-[inset_0_0_1em_rgba(0,0,0,0.8)]">
<motion.div aria-hidden style={{ opacity: glow }} className="absolute inset-0 bg-[radial-gradient(ellipse_at_50%_60%,#ffdf9a,#f0ac45_60%,#b86f1c)]" />
{near < 0 && !reduce && (
<motion.div
aria-hidden
style={{ backgroundImage: NOISE }}
className="absolute inset-0 opacity-30 mix-blend-multiply"
animate={{ backgroundPosition: ["0px 0px", "31px 17px", "-23px 9px", "11px -13px"] }}
transition={{ duration: 0.28, repeat: Infinity, ease: "linear" }}
/>
)}
<div className="absolute inset-x-[1em] top-[0.4em] bottom-[0.4em]">
{/* Vector ticks: a CSS stripe this thin drops lines on small screens. */}
<svg aria-hidden viewBox="0 0 100 10" preserveAspectRatio="none" className="absolute inset-x-0 top-[5.3em] h-[1.2em] w-full text-[#3b2004]">
<path d={TICKS} fill="none" stroke="currentColor" strokeWidth="1" vectorEffect="non-scaling-stroke" />
</svg>
{SCALE.map((n, i) => (
<span key={n} aria-hidden style={{ left: `${(i * 100) / (SCALE.length - 1)}%` }} className="absolute top-[8.3em] -translate-x-1/2 font-mono text-[0.8em] text-[#3b2004]">
{n}
</span>
))}
{stations.map((s, i) => (
<a
key={s.href}
href={s.href}
aria-current={near === i ? "page" : undefined}
onClick={(e) => {
onSelect?.(s, e);
tune(i);
}}
style={{ left: `${spot(i, count) * 100}%`, top: i % 2 ? "2.4em" : "0.3em" }}
className={`absolute -translate-x-1/2 whitespace-nowrap rounded-[0.2em] px-[0.25em] font-serif text-[1.25em] font-bold outline-offset-1 outline-[#3b2004] transition-colors hover:text-[#8c1d10] focus-visible:outline-2 ${
near === i ? "text-[#8c1d10]" : "text-[#3b2004]"
}`}
>
{s.label}
</a>
))}
<motion.span aria-hidden style={{ left: needle }} className="pointer-events-none absolute inset-y-0 -ml-[0.07em] w-[0.14em] bg-[#c21f12] shadow-[0_0_0.4em_rgba(194,31,18,0.8)]" />
</div>
</div>
{/* Lower panel */}
<div className="flex min-h-0 flex-1 items-center gap-[1em]">
<div className="relative grid h-full min-w-0 flex-1 place-items-center overflow-hidden rounded-[0.5em] border-[0.14em] border-[#2a1407] bg-[#7b6a4c] bg-[repeating-linear-gradient(45deg,rgba(0,0,0,0.25)_0_0.1em,transparent_0.1em_0.28em),repeating-linear-gradient(-45deg,rgba(0,0,0,0.25)_0_0.1em,transparent_0.1em_0.28em)]">
{tuned ? (
<a
href={tuned.href}
onClick={(e) => onSelect?.(tuned, e)}
className="max-w-full truncate rounded-[0.25em] bg-[#21120a] px-[0.8em] py-[0.35em] font-serif text-[1.15em] font-bold text-[#ffd98e] outline-offset-2 outline-[#ffd98e] hover:bg-black focus-visible:outline-2"
>
{tuned.label} ▸
</a>
) : (
<span className="rounded-[0.25em] bg-[#21120a] px-[0.8em] py-[0.35em] font-mono text-[1em] text-[#a07c4a]">static…</span>
)}
</div>
<motion.span aria-hidden style={{ background: eye }} className="size-[3.5em] shrink-0 rounded-full border-[0.3em] border-[#1c1006] shadow-[0_0_0.8em_rgba(99,242,162,0.45)]" />
<motion.div
ref={knob}
role="slider"
tabIndex={0}
aria-label="Tuning knob"
aria-valuemin={0}
aria-valuemax={count - 1}
aria-valuenow={Math.max(near, 0)}
aria-valuetext={tuned?.label ?? "Between stations"}
onPointerDown={grab}
onPointerMove={drag}
onPointerUp={drop}
onPointerCancel={drop}
onKeyDown={key}
style={{ rotate: turn }}
className="relative size-[7.6em] shrink-0 cursor-grab touch-none rounded-full bg-[repeating-conic-gradient(#23120a_0deg_7deg,#59321a_7deg_14deg)] outline-offset-4 outline-[#ffd98e] shadow-[0_0.25em_0.5em_rgba(0,0,0,0.7)] focus-visible:outline-2 active:cursor-grabbing"
>
<span className="absolute inset-[0.7em] rounded-full bg-[radial-gradient(circle_at_35%_30%,#6d4628,#2b170b)] shadow-[inset_0_0.06em_0.1em_rgba(255,220,180,0.4)]" />
<span className="absolute left-1/2 top-[0.9em] h-[1.4em] w-[0.2em] -translate-x-1/2 rounded-full bg-[#ffd98e]" />
</motion.div>
</div>
</div>
</nav>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/radio-dial-nav.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/radio-dial-nav-demo.tsx
"use client"; import { RadioDialNav } from "@/components/ui/radio-dial-nav"; const STATIONS = [ { label: "Home", href: "#home" }, { label: "Shows", href: "#shows" }, { label: "Archive", href: "#archive" }, { label: "Team", href: "#team" }, { label: "Contact", href: "#contact" }, ]; export default function RadioDialNavExample() { return ( <div className="grid h-full place-items-center overflow-hidden bg-[#2a221b] bg-[radial-gradient(circle_at_50%_30%,#4a3a2c,transparent_75%)] p-2"> <RadioDialNav stations={STATIONS} start={1} onSelect={(_, e) => e.preventDefault()} /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| stations | { label, href }[] | — | Pages left to right, 2 to 6. |
| start | number | 0 | Index of the station tuned at the start. |
| onSelect | (station, event) => void | — | Called when a station link is pressed. The knob only tunes, it never opens a page. |
| label | string | "Site stations" | Name read out by screen readers. |
| className | string | "" | Size. Everything inside scales with the width. |