Navigation
OrbitNavigation
Press the core and the links sweep out round it like moons settling into orbit. Esc or a second press brings them home.
Download
- React
- Tailwind CSS v4
- motion
components/ui/orbit-navigation.tsx
"use client";
import { useRef, useState, type MouseEvent, type ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
export type OrbitItem = {
label: string;
href: string;
/** Icon in the link (an svg works best). */
icon: ReactNode;
};
type OrbitNavigationProps = {
items: OrbitItem[];
/** Distance from the centre to each link, in pixels. */
radius?: number;
/** Angle of the first link in degrees. -90 is straight up. */
start?: number;
/** How much of the circle the links cover, in degrees. 360 = full ring. */
spread?: number;
/** Called on click. Call e.preventDefault() to stop the browser following the link. */
onSelect?: (item: OrbitItem, e: MouseEvent<HTMLAnchorElement>) => void;
/** Colour of the centre button and ring. */
color?: string;
/** Name read out by screen readers. */
label?: string;
className?: string;
};
/* OrbitNavigation: press the core and the links sweep out round it like moons settling into orbit. */
export function OrbitNavigation({
items,
radius = 88,
start = -90,
spread = 360,
onSelect,
color = "#a78bfa",
label = "Menu",
className = "text-white",
}: OrbitNavigationProps) {
const reduce = useReducedMotion();
const core = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const n = items.length;
const step = spread >= 360 ? spread / n : spread / Math.max(n - 1, 1);
const box = radius * 2 + 64;
const close = () => {
setOpen(false);
core.current?.focus();
};
return (
<nav
aria-label={label}
className={`relative grid place-items-center ${className}`}
style={{ width: box, height: box }}
onKeyDown={(e) => {
if (e.key === "Escape" && open) close();
}}
>
<motion.span
aria-hidden
initial={false}
animate={{ scale: open ? 1 : 0.3, opacity: open ? 1 : 0 }}
transition={reduce ? { duration: 0.15 } : { type: "spring", stiffness: 200, damping: 22 }}
className="absolute rounded-full border border-dashed"
style={{ width: radius * 2, height: radius * 2, borderColor: `color-mix(in srgb, ${color} 40%, transparent)` }}
/>
{items.map((item, i) => {
const angle = start + step * i;
const delay = reduce ? 0 : open ? i * 0.04 : (n - 1 - i) * 0.03;
const move = reduce
? { duration: 0.15 }
: { type: "spring" as const, stiffness: 170, damping: 18, delay };
return (
<motion.div
key={item.href}
initial={false}
animate={{ rotate: open ? angle : start }}
transition={move}
className="absolute top-1/2 left-1/2 size-0"
>
<motion.a
href={item.href}
aria-label={item.label}
inert={!open}
onClick={(e) => {
onSelect?.(item, e);
close();
}}
initial={false}
animate={{
x: open ? radius : 0,
rotate: open ? -angle : -start,
opacity: open ? 1 : 0,
scale: open ? 1 : 0.4,
}}
transition={move}
className="group absolute -top-5 -left-5 grid size-10 place-items-center rounded-full border border-white/15 bg-[#16132a] outline-none hover:border-white/40 focus-visible:ring-2 focus-visible:ring-white/80 [&>svg]:size-[18px]"
>
{item.icon}
<span
aria-hidden
className="pointer-events-none absolute top-full mt-1.5 whitespace-nowrap rounded-md bg-black/80 px-2 py-0.5 text-[11px] opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
>
{item.label}
</span>
</motion.a>
</motion.div>
);
})}
<button
ref={core}
type="button"
aria-expanded={open}
aria-label={open ? `Close ${label}` : `Open ${label}`}
onClick={() => setOpen((v) => !v)}
className="relative grid size-14 place-items-center rounded-full text-[#0b0a14] outline-none focus-visible:ring-2 focus-visible:ring-white/80 focus-visible:ring-offset-2 focus-visible:ring-offset-black"
style={{
background: color,
boxShadow: `0 0 0 6px color-mix(in srgb, ${color} 15%, transparent), 0 10px 30px -8px ${color}`,
}}
>
<motion.span
aria-hidden
animate={{ rotate: open ? 135 : 0 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 18 }}
className="relative grid size-5 place-items-center"
>
<span className="absolute h-0.5 w-5 rounded-full bg-current" />
<span className="absolute h-5 w-0.5 rounded-full bg-current" />
</motion.span>
</button>
</nav>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/orbit-navigation.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/orbit-navigation-demo.tsx
"use client"; import { Briefcase, House, Mail, Settings, User } from "lucide-react"; import { OrbitNavigation } from "@/components/ui/orbit-navigation"; const ITEMS = [ { label: "Home", href: "#home", icon: <House /> }, { label: "About", href: "#about", icon: <User /> }, { label: "Work", href: "#work", icon: <Briefcase /> }, { label: "Contact", href: "#contact", icon: <Mail /> }, { label: "Settings", href: "#settings", icon: <Settings /> }, ]; export default function OrbitNavigationExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14]"> <OrbitNavigation items={ITEMS} radius={80} onSelect={(_, e) => e.preventDefault()} /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | { label, href, icon }[] | — | The links. icon = an svg. |
| radius | number | 88 | Distance from the centre to each link, in pixels. |
| start | number | -90 | Angle of the first link in degrees. -90 is straight up. |
| spread | number | 360 | How much of the circle the links cover, in degrees. |
| onSelect | (item, e) => void | — | Click handler. Call e.preventDefault() to stay on the page. |
| color | string | "#a78bfa" | Core and ring colour. |
| label | string | "Menu" | Name read out by screen readers. |
| className | string | "text-white" | Extra classes on the wrapper. |