Navigation
CompassNav
A brass ship's compass with your pages round the card. The needle follows the pointer like a magnet, swings back and forth before it settles on the page you press, and the bearing counts along with it.
Download
- React
- Tailwind CSS v4
- motion
components/ui/compass-nav.tsx
"use client";
import { useEffect, useId, useRef, useState, type MouseEvent, type PointerEvent } from "react";
import { useAnimationFrame, useInView, useMotionValue, useReducedMotion, useSpring } from "motion/react";
type Heading = {
label: string;
href: string;
};
type CompassNavProps = {
/** Pages round the card, clockwise from the top, 2 to 8. Short names fit best. */
items: Heading[];
/** Index of the page the needle points at first. */
start?: number;
/** Called on press. Call event.preventDefault() to stay on the page. */
onSelect?: (item: Heading, event: MouseEvent<Element>) => void;
/** Name read out by screen readers. */
label?: string;
className?: string;
};
const MID = 200;
const polar = (deg: number, r: number) => {
const a = ((deg - 90) * Math.PI) / 180;
return { x: MID + r * Math.cos(a), y: MID + r * Math.sin(a) };
};
const star = (deg: number, long: number, wide: number) => {
const tip = polar(deg, long);
const left = polar(deg - 45, wide);
const right = polar(deg + 45, wide);
return {
dark: `${MID},${MID} ${tip.x},${tip.y} ${left.x},${left.y}`,
light: `${MID},${MID} ${tip.x},${tip.y} ${right.x},${right.y}`,
};
};
const ROSE = [45, 135, 225, 315, 0, 90, 180, 270].map((deg, i) => star(deg, i < 4 ? 76 : 112, i < 4 ? 20 : 26));
const TICKS = Array.from({ length: 36 }, (_, i) => i * 10);
/* CompassNav: a brass ship's compass. Pages sit round the card; the needle follows your pointer like a magnet, and swings to rest on the one you press. */
export function CompassNav({ items, start = 0, onSelect, label = "Site compass", className = "" }: CompassNavProps) {
const reduce = useReducedMotion();
const uid = useId().replace(/[^a-z0-9]/gi, "");
const face = useRef<SVGSVGElement>(null);
const needle = useRef<SVGGElement>(null);
const degrees = useRef<SVGTSpanElement>(null);
const seen = useInView(face);
const [active, setActive] = useState(start);
const bearing = (i: number) => (360 * i) / items.length;
const aim = useMotionValue(bearing(start));
const swing = useSpring(aim, { stiffness: 58, damping: 5.5 });
const point = (deg: number) => {
const now = aim.get();
const next = now + ((deg - now + 540) % 360) - 180;
aim.set(next);
if (reduce) swing.jump(next);
};
// Reduced motion has no animation frames to lean on, so set the needle here.
useEffect(() => {
if (reduce) needle.current?.setAttribute("transform", `rotate(${bearing(active)} ${MID} ${MID})`);
});
useAnimationFrame((t) => {
if (!seen || reduce) return;
const deg = swing.get() + Math.sin(t / 830) * 0.8;
needle.current?.setAttribute("transform", `rotate(${deg} ${MID} ${MID})`);
if (degrees.current) degrees.current.textContent = `${String(Math.round(((deg % 360) + 360) % 360) % 360).padStart(3, "0")}°`;
});
const follow = (e: PointerEvent<SVGSVGElement>) => {
const box = e.currentTarget.getBoundingClientRect();
const x = e.clientX - box.left - box.width / 2;
const y = e.clientY - box.top - box.height / 2;
if (Math.hypot(x, y) > box.width * 0.07) point((Math.atan2(y, x) * 180) / Math.PI + 90);
};
return (
<nav aria-label={label} className={`w-full max-w-[440px] ${className}`}>
<svg ref={face} viewBox="0 0 400 400" className="block h-auto w-full touch-manipulation select-none" onPointerMove={follow} onPointerLeave={() => point(bearing(active))}>
<defs>
<radialGradient id={`${uid}b`} cx="38%" cy="30%" r="80%">
<stop offset="0" stopColor="#f6e1a0" />
<stop offset="0.5" stopColor="#b98c3a" />
<stop offset="1" stopColor="#5e4114" />
</radialGradient>
<radialGradient id={`${uid}c`} cx="50%" cy="46%" r="60%">
<stop offset="0" stopColor="#f9f0d9" />
<stop offset="0.8" stopColor="#ecdcb6" />
<stop offset="1" stopColor="#c8ae7d" />
</radialGradient>
</defs>
<circle cx={MID} cy={MID} r="198" fill={`url(#${uid}b)`} />
<circle cx={MID} cy={MID} r="182" fill="none" stroke="#4e3510" strokeWidth="2" />
<circle cx={MID} cy={MID} r="176" fill={`url(#${uid}c)`} stroke="#6b4c1b" strokeWidth="2" />
{TICKS.map((deg) => {
const a = polar(deg, 176);
const b = polar(deg, deg % 30 ? 170 : 163);
return <line key={deg} x1={a.x} y1={a.y} x2={b.x} y2={b.y} stroke="#6b4c1b" strokeWidth={deg % 30 ? 1 : 2} />;
})}
<circle cx={MID} cy={MID} r="116" fill="none" stroke="#a48b5c" strokeWidth="1" />
{ROSE.map((p, i) => (
<g key={i}>
<polygon points={p.dark} fill="#bba77c" />
<polygon points={p.light} fill="#f1e6c8" stroke="#a48b5c" strokeWidth="0.8" />
</g>
))}
{items.map((item, i) => {
const deg = bearing(i);
const spot = polar(deg, 133);
const on = i === active;
return (
<a
key={item.href}
href={item.href}
aria-current={on ? "page" : undefined}
className="group outline-none"
onFocus={() => point(deg)}
onBlur={() => point(bearing(active))}
onClick={(e) => {
onSelect?.(item, e);
setActive(i);
point(deg);
}}
>
<circle cx={spot.x} cy={spot.y} r="30" fill="transparent" />
<circle cx={spot.x} cy={spot.y} r="29" fill="none" stroke="#7a1c12" strokeWidth="1.6" strokeDasharray="4 3" className="opacity-0 group-focus-visible:opacity-100" />
<text
x={spot.x}
y={spot.y + 6}
textAnchor="middle"
fontSize="17"
fontWeight="700"
fill={on ? "#a01f12" : "#2e2412"}
className="font-serif transition-colors group-hover:fill-[#a01f12]"
>
{item.label}
</text>
</a>
);
})}
<g ref={needle} transform={`rotate(${bearing(start)} ${MID} ${MID})`} className="pointer-events-none">
<polygon points="200,84 214,200 186,200" fill="#c9301d" />
<polygon points="200,84 200,200 186,200" fill="#8c1d10" />
<polygon points="200,316 214,200 186,200" fill="#2d3640" />
<polygon points="200,316 200,200 186,200" fill="#454f5b" />
</g>
<rect className="pointer-events-none" x="168" y="244" width="64" height="22" rx="4" fill="#2b2112" />
<text x={MID} y="260" textAnchor="middle" fill="#f3d58f" className="pointer-events-none font-mono" fontSize="15">
<tspan ref={degrees}>{`${String(Math.round(bearing(start))).padStart(3, "0")}°`}</tspan>
</text>
<circle cx={MID} cy={MID} r="13" fill={`url(#${uid}b)`} stroke="#4e3510" strokeWidth="1.5" className="pointer-events-none" />
<ellipse cx="150" cy="112" rx="104" ry="58" fill="#fff" opacity="0.1" transform="rotate(-32 150 112)" className="pointer-events-none" />
</svg>
</nav>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/compass-nav.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/compass-nav-demo.tsx
"use client"; import { CompassNav } from "@/components/ui/compass-nav"; const ITEMS = [ { label: "Home", href: "#home" }, { label: "Tours", href: "#tours" }, { label: "Maps", href: "#maps" }, { label: "Crew", href: "#crew" }, ]; export default function CompassNavExample() { return ( // The dial is square: min() keeps it inside a short box too. <div className="grid h-full place-items-center overflow-hidden bg-[#10303a] bg-[linear-gradient(rgba(255,255,255,0.05)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.05)_1px,transparent_1px)] bg-[size:32px_32px] [container-type:size]"> <div className="w-[min(94cqw,94cqh)]"> <CompassNav items={ITEMS} onSelect={(_, e) => e.preventDefault()} /> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | { label, href }[] | — | Pages clockwise from the top, 2 to 8. Short names fit best. |
| start | number | 0 | Index of the page the needle points at first. |
| onSelect | (item, event) => void | — | Called on press. Call event.preventDefault() to stay on the page. |
| label | string | "Site compass" | Name read out by screen readers. |
| className | string | "" | Size. The dial is square, so give it a width that fits the height. |