"use client"; import { useRef, useState, type FocusEvent, type MouseEvent, type PointerEvent, type ReactNode } from "react"; import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue } from "motion/react"; export type FloatingDockItem = { label: string; href: string; /** Icon (an svg works best). */ icon: ReactNode; }; type FloatingDockProps = { items: FloatingDockItem[]; /** href of the active link at start. Leave out for no marker. */ active?: string; /** Called on click. Call e.preventDefault() to stop the browser following the link. */ onSelect?: (item: FloatingDockItem, e: MouseEvent) => void; /** Icon size at rest in pixels. Icons at rest set the dock's width, so make sure they fit. */ size?: number; /** How many times bigger the icon under the pointer gets. */ magnify?: number; /** Pixels from the pointer where icons start to grow. */ range?: number; /** Colour of the active marker. */ accent?: string; /** Name read out by screen readers. */ label?: string; /** Look of the bar. To float it, wrap it in a fixed box. */ className?: string; }; const GAP = 8; const PAD = 10; const GROW = { stiffness: 420, damping: 30, mass: 0.5 }; /* FloatingDock: a row of icons that swell as the pointer passes, like a wave following it. Hover or keyboard focus shows the name. */ export function FloatingDock({ items, active, onSelect, size = 44, magnify = 1.7, range = 130, accent = "#a78bfa", label = "Dock", className = "border border-white/10 bg-[#15131f]/85 text-white shadow-xl backdrop-blur-md", }: FloatingDockProps) { const reduce = useReducedMotion(); const [current, setCurrent] = useState(active); const box = useRef(null); // Where the pointer is along the dock at rest. Infinity = away, so nothing grows. const pointer = useMotionValue(Infinity); const centre = (i: number) => PAD + i * (size + GAP) + size / 2; const restWidth = centre(items.length - 1) + size / 2 + PAD; const restHeight = size + PAD * 2; // Measured on the outer box, which never changes size, so growing icons cannot feed back into the reading. const follow = (e: PointerEvent) => { if (e.pointerType === "touch" || !box.current) return; const x = e.clientX - box.current.getBoundingClientRect().left; pointer.set(Math.max(centre(0), Math.min(centre(items.length - 1), x))); }; const onFocus = (e: FocusEvent, i: number) => { if (e.currentTarget.matches(":focus-visible")) pointer.set(centre(i)); }; return ( ); } type DockItemProps = { item: FloatingDockItem; on: boolean; rest: number; pointer: MotionValue; size: number; magnify: number; range: number; accent: string; onFocus: (e: FocusEvent) => void; onBlur: () => void; onClick: (e: MouseEvent) => void; }; function DockItem({ item, on, rest, pointer, size, magnify, range, accent, onFocus, onBlur, onClick }: DockItemProps) { // Cosine falloff: full size under the pointer, easing to rest at the edge of the range. const target = useTransform(pointer, (x) => { const away = Math.abs(x - rest); return away >= range ? size : size * (1 + ((magnify - 1) * (1 + Math.cos((Math.PI * away) / range))) / 2); }); const width = useSpring(target, GROW); return ( {item.icon} {item.label} {on && } ); }