Radial Nav
A constellation of destinations around one central idea. Open up and choose a direction.
"use client";
import { useCallback, useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react';
import './radial-nav.css';
export type RadialNavItem = {
id: string;
label: string;
href: string;
icon: ReactNode;
};
export type RadialNavProps = {
items: RadialNavItem[];
activeId?: string;
className?: string;
label?: string;
onNavigate?: (id: string, event: MouseEvent<HTMLAnchorElement>) => void;
onOpenChange?: (open: boolean) => void;
};
/** A disclosure with ordinary links arranged around its central trigger. */
export function RadialNav({ items, activeId, className = '', label = 'Explore', onNavigate, onOpenChange }: RadialNavProps) {
const [open, setOpen] = useState(false);
const openRef = useRef(false);
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const links = useRef<Array<HTMLAnchorElement | null>>([]);
const focusOnOpen = useRef<'first' | 'last' | null>(null);
const panelId = useId();
const changeOpen = useCallback((next: boolean, restoreFocus = false) => {
if (restoreFocus) trigger.current?.focus();
if (openRef.current === next) return;
openRef.current = next;
setOpen(next);
onOpenChange?.(next);
}, [onOpenChange]);
useEffect(() => {
if (!open) return;
if (focusOnOpen.current) {
links.current[focusOnOpen.current === 'first' ? 0 : items.length - 1]?.focus();
focusOnOpen.current = null;
}
const dismiss = (event: PointerEvent) => {
if (event.target instanceof Node && !root.current?.contains(event.target)) changeOpen(false);
};
document.addEventListener('pointerdown', dismiss);
return () => document.removeEventListener('pointerdown', dismiss);
}, [open, items.length, changeOpen]);
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Escape' && openRef.current) {
event.preventDefault();
event.stopPropagation();
changeOpen(false, true);
return;
}
const forward = event.key === 'ArrowRight' || event.key === 'ArrowDown';
const backward = event.key === 'ArrowLeft' || event.key === 'ArrowUp';
if (!forward && !backward && event.key !== 'Home' && event.key !== 'End') return;
if (!items.length) return;
event.preventDefault();
if (!openRef.current) {
focusOnOpen.current = backward || event.key === 'End' ? 'last' : 'first';
changeOpen(true);
return;
}
const current = links.current.findIndex((element) => element === event.target);
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : current < 0 ? backward ? items.length - 1 : 0 : (current + (backward ? -1 : 1) + items.length) % items.length;
links.current[next]?.focus();
}
return <div
ref={root}
className={`ac-radial-nav ${className}`.trim()}
data-open={open}
onKeyDown={handleKeyDown}
onBlur={(event) => {
if (event.relatedTarget instanceof Node && !event.currentTarget.contains(event.relatedTarget)) changeOpen(false);
}}
>
<div className="ac-radial-orbit" aria-hidden="true" />
<button
ref={trigger}
type="button"
className="ac-radial-trigger"
aria-expanded={open}
aria-controls={panelId}
aria-label={`${label} navigation`}
onClick={() => changeOpen(!openRef.current)}
>
<span className="ac-radial-symbol" aria-hidden="true"><span /><span /><span /><span /></span>
<span className="ac-radial-trigger-label">{label}</span>
</button>
<nav id={panelId} aria-label={label} hidden={!open} className="ac-radial-links">
{open && items.map((item, index) => {
const angle = index / items.length * Math.PI * 2 - Math.PI / 2;
const style = {
'--ac-radial-x': Math.cos(angle),
'--ac-radial-y': Math.sin(angle),
'--ac-radial-delay': `${Math.min(index * 25, 140)}ms`,
} as CSSProperties;
return <a
key={item.id}
ref={(element) => { links.current[index] = element; }}
className="ac-radial-link"
href={item.href}
style={style}
aria-current={activeId === item.id ? 'page' : undefined}
onClick={(event) => {
onNavigate?.(item.id, event);
changeOpen(false, true);
}}
>
<span className="ac-radial-icon" aria-hidden="true">{item.icon}</span>
<span className="ac-radial-link-label">{item.label}</span>
</a>;
})}
</nav>
</div>;
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/radial-nav.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { RadialNav } from '@/components/anticraft/RadialNav';
export function Example() {
const icon = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M5 19 19 5M5 5h14v14" /></svg>;
return (
<RadialNav
label="Explore"
activeId="work"
items={[
{ id: 'work', label: 'Work', href: '/#work', icon },
{ id: 'about', label: 'About', href: '/#about', icon },
{ id: 'journal', label: 'Journal', href: '/#journal', icon },
{ id: 'contact', label: 'Contact', href: '/#contact', icon },
]}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | RadialNavItem[] | Required | Destinations with a unique id, visible label, and real href. Replace the example URLs with destinations in your project. |
activeId | string | undefined | The controlled id of the current destination. Only that link receives aria-current="page". Update this from your route or selected section. |
label | string | 'Explore' | The accessible navigation label. Disclosure components also use it for their visible trigger. |
onNavigate | (id: string, event: MouseEvent<HTMLAnchorElement>) => void | undefined | Runs when a destination is activated. Anchors navigate normally; call event.preventDefault() only when handling navigation yourself, such as a local preview or router adapter. |
className | string | '' | Additional classes for sizing and CSS custom properties. Each component imports its matching stylesheet; keep both files together. |
items[].icon | ReactNode | Required | Decorative artwork for a destination. Use short labels and a small group of destinations so links have room around the orbit. |
onOpenChange | (open: boolean) => void | undefined | Receives changes to the internal disclosure state when visitors open, dismiss, or navigate. |
Motion & accessibility
Click or tap the center to reveal the destinations. Enter and Space operate its native button. Arrow keys open the orbit and move between links; Home and End reach its edges. Tab also follows the normal document order. Escape closes and restores trigger focus; outside presses, focus leaving, and link activation dismiss it. Closed links are hidden from interaction and assistive technology. Reduced motion reveals the links without radial animation. Set --ac-radial-height for the stage and --ac-surface, --ac-ink, and --ac-accent for its palette.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.