Navigation
DockSphere
A dock of glossy spheres. Spheres near the pointer swell and the rest stay small; keyboard focus swells them too. Each shows its name on hover.
Download
- React
- Tailwind CSS v4
- motion
components/ui/dock-sphere.tsx
"use client";
import { useRef, type MouseEvent, type ReactNode } from "react";
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
} from "motion/react";
export type DockSphereItem = {
label: string;
href: string;
/** Icon inside the sphere (an svg works best). */
icon: ReactNode;
/** Sphere colour. */
color?: string;
};
type DockSphereProps = {
items: DockSphereItem[];
/** Resting sphere size in pixels. */
size?: number;
/** Size of the sphere right under the pointer, as a multiple of `size`. */
magnify?: number;
/** Pixels from the pointer where spheres start to grow. */
reach?: number;
/** Called on click. Call e.preventDefault() to stop the browser following the link. */
onSelect?: (item: DockSphereItem, e: MouseEvent<HTMLAnchorElement>) => void;
/** Name read out by screen readers. */
label?: string;
className?: string;
};
/* DockSphere: a dock of glossy spheres. Spheres near the pointer swell, the rest stay small. */
export function DockSphere({
items,
size = 44,
magnify = 1.7,
reach = 140,
onSelect,
label = "Dock",
className = "rounded-full border border-white/10 bg-white/5 px-4 backdrop-blur-md",
}: DockSphereProps) {
const mouseX = useMotionValue(Infinity);
return (
<nav
aria-label={label}
onPointerMove={(e) => mouseX.set(e.clientX)}
onPointerLeave={() => mouseX.set(Infinity)}
className={`inline-flex items-end gap-3 pb-3 ${className}`}
style={{ height: size + 24 }}
>
{items.map((item) => (
<Sphere
key={item.href}
item={item}
mouseX={mouseX}
size={size}
magnify={magnify}
reach={reach}
onSelect={onSelect}
/>
))}
</nav>
);
}
function Sphere({
item,
mouseX,
size,
magnify,
reach,
onSelect,
}: {
item: DockSphereItem;
mouseX: MotionValue<number>;
size: number;
magnify: number;
reach: number;
onSelect?: DockSphereProps["onSelect"];
}) {
const reduce = useReducedMotion();
const ref = useRef<HTMLAnchorElement>(null);
const distance = useTransform(mouseX, (x) => {
const r = ref.current?.getBoundingClientRect();
if (!r) return reach;
return x - (r.left + r.width / 2);
});
const target = useTransform(distance, [-reach, 0, reach], [size, size * magnify, size]);
const width = useSpring(target, { stiffness: 320, damping: 22, mass: 0.2 });
const c = item.color ?? "#a78bfa";
return (
<motion.a
ref={ref}
href={item.href}
aria-label={item.label}
onClick={(e) => onSelect?.(item, e)}
onFocus={() => {
const r = ref.current?.getBoundingClientRect();
if (r) mouseX.set(r.left + r.width / 2);
}}
onBlur={() => mouseX.set(Infinity)}
style={{ width: reduce ? size : width, height: reduce ? size : width }}
className="group relative grid shrink-0 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-white/80 focus-visible:ring-offset-2 focus-visible:ring-offset-black"
>
<span
aria-hidden
className="absolute inset-0 rounded-full"
style={{
background: `radial-gradient(circle at 32% 26%, rgba(255,255,255,0.9), ${c} 36%, color-mix(in srgb, ${c} 40%, black) 100%)`,
boxShadow: `inset 0 -6px 12px rgba(0,0,0,0.35), 0 10px 20px -8px ${c}`,
}}
/>
<span aria-hidden className="relative grid size-1/2 place-items-center text-white drop-shadow [&>svg]:size-full">
{item.icon}
</span>
<span
aria-hidden
className="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-md bg-black/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
>
{item.label}
</span>
</motion.a>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/dock-sphere.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/dock-sphere-demo.tsx
"use client"; import { Camera, House, Mail, Music, Search } from "lucide-react"; import { DockSphere } from "@/components/ui/dock-sphere"; const ITEMS = [ { label: "Home", href: "#home", icon: <House />, color: "#a78bfa" }, { label: "Search", href: "#search", icon: <Search />, color: "#22d3ee" }, { label: "Music", href: "#music", icon: <Music />, color: "#f472b6" }, { label: "Photos", href: "#photos", icon: <Camera />, color: "#fbbf24" }, { label: "Mail", href: "#mail", icon: <Mail />, color: "#34d399" }, ]; export default function DockSphereExample() { return ( <div className="grid h-full place-items-center bg-[radial-gradient(circle_at_50%_0%,#2a2150,#0b0a14_70%)] p-6 pt-14"> <DockSphere items={ITEMS} size={36} magnify={1.8} onSelect={(_, e) => e.preventDefault()} /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | { label, href, icon, color? }[] | — | The links. icon = an svg; color = sphere colour. |
| size | number | 44 | Resting sphere size in pixels. |
| magnify | number | 1.7 | Size under the pointer, as a multiple of size. |
| reach | number | 140 | Pixels from the pointer where spheres start to grow. |
| onSelect | (item, e) => void | — | Click handler. Call e.preventDefault() to stay on the page. |
| label | string | "Dock" | Name read out by screen readers. |
| className | string | glass tray | Replaces the tray look. |