Navigationv0.1.0
Dock
Icons magnify based on cursor distance, with tooltips.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/dock.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/dock/dock.tsx"use client"; import { useEffect, useRef, useState, type ComponentProps, type KeyboardEvent, type ReactNode, type RefObject } from "react"; import { AnimatePresence, motion, useAnimate, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue, } from "motion/react"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; export interface DockItem { /** Unique id. */ id: string; /** Tooltip text and accessible name. */ label: string; /** Icon content, usually a lucide icon. */ icon: ReactNode; /** Classes for the icon tile, e.g. a gradient background. */ className?: string; /** Renders the item as a link. */ href?: string; /** Click handler for button items. */ onClick?: () => void; /** Shows a running indicator under the icon. */ active?: boolean; } export interface DockProps extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** The dock items, in order. */ items: DockItem[]; /** Resting icon size in px. @default 48 */ iconSize?: number; /** Scale of the icon right under the cursor. @default 1.7 */ magnification?: number; /** Distance in px over which neighbours also grow. @default 140 */ distance?: number; } const GAP = 8; const HOVER_SPRING = { stiffness: 320, damping: 30, mass: 0.6 }; /** Smooth bell curve: 1 under the cursor, 0 at `distance` and beyond. */ function influence(offset: number, distance: number) { const t = Math.min(Math.abs(offset) / distance, 1); return 0.5 + 0.5 * Math.cos(Math.PI * t); } interface DockState { pointerX: MotionValue<number>; hover: MotionValue<number>; centers: RefObject<number[]>; iconSize: number; magnification: number; distance: number; } function scaleOf(state: DockState, center: number, x: number, h: number) { return 1 + (state.magnification - 1) * influence(x - center, state.distance) * h; } /** How far item `index` shifts so magnified neighbours never overlap and the row stays centered. */ function offsetOf(state: DockState, index: number, x: number, h: number) { const centers = state.centers.current; let before = 0; let total = 0; for (let j = 0; j < centers.length; j++) { const extra = state.iconSize * (scaleOf(state, centers[j], x, h) - 1); total += extra; if (j < index) before += extra; if (j === index) before += extra / 2; } return before - total / 2; } function DockIcon({ item, index, state, focusable, onFocusItem, onBlurItem, itemRef, }: { item: DockItem; index: number; state: DockState; focusable: boolean; onFocusItem: (index: number) => void; onBlurItem: () => void; itemRef: (node: HTMLElement | null) => void; }) { const reduceMotion = useReducedMotion(); const [hovered, setHovered] = useState(false); const [focused, setFocused] = useState(false); const [bounce, animateBounce] = useAnimate<HTMLSpanElement>(); const scale = useTransform([state.pointerX, state.hover], ([x, h]: number[]) => reduceMotion ? 1 : scaleOf(state, state.centers.current[index] ?? 0, x, h), ); const x = useTransform([state.pointerX, state.hover], ([px, h]: number[]) => (reduceMotion ? 0 : offsetOf(state, index, px, h))); const tooltipY = useTransform(scale, (s) => -(s - 1) * state.iconSize); const Tag = item.href ? "a" : "button"; const showLabel = hovered || focused; return ( <motion.div style={{ x, width: state.iconSize, height: state.iconSize }} className="relative shrink-0"> <AnimatePresence> {showLabel ? ( <motion.span aria-hidden="true" style={{ y: tooltipY }} className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-3 -translate-x-1/2" > <motion.span initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, transition: { duration: duration.instant } }} transition={{ duration: duration.quick, ease: ease.out }} className="block rounded-md bg-foreground px-2 py-1 text-xs font-medium whitespace-nowrap text-background shadow-md" > {item.label} </motion.span> </motion.span> ) : null} </AnimatePresence> <Tag ref={itemRef as never} href={item.href} type={item.href ? undefined : "button"} aria-label={item.label} tabIndex={focusable ? 0 : -1} onClick={() => { item.onClick?.(); // A small launch bounce, like a dock icon opening its app. if (!reduceMotion && bounce.current) animateBounce(bounce.current, { y: [0, -16, 0, -6, 0] }, { duration: 0.6, ease: "easeOut" }); }} onPointerEnter={() => setHovered(true)} onPointerLeave={() => setHovered(false)} onFocus={() => { setFocused(true); onFocusItem(index); }} onBlur={() => { setFocused(false); onBlurItem(); }} className="group block size-full rounded-[22%] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" > <span ref={bounce} className="block size-full"> <motion.span style={{ scale, originY: 1 }} className={cn( "flex size-full items-center justify-center rounded-[22%] border border-border bg-background text-foreground shadow-sm [&_svg]:size-[45%]", item.className, )} > {item.icon} </motion.span> </span> </Tag> {item.active ? ( <span aria-hidden="true" className="absolute -bottom-2 left-1/2 size-1 -translate-x-1/2 rounded-full bg-foreground/70" /> ) : null} </motion.div> ); } export function Dock({ items, iconSize = 48, magnification = 1.7, distance = 140, className, "aria-label": ariaLabel = "Dock", ...props }: DockProps) { const listRef = useRef<HTMLDivElement>(null); const itemRefs = useRef<(HTMLElement | null)[]>([]); const centers = useRef<number[]>([]); const pointerX = useMotionValue(0); const hoverTarget = useMotionValue(0); const hover = useSpring(hoverTarget, HOVER_SPRING); const [focusIndex, setFocusIndex] = useState(0); const reduceMotion = useReducedMotion(); const state: DockState = { pointerX, hover, centers, iconSize, magnification, distance }; // Item centers in the list's own coordinates. Transforms don't change layout, so these stay valid. useEffect(() => { const list = listRef.current; if (!list) return; const measure = () => { centers.current = Array.from(list.children as HTMLCollectionOf<HTMLElement>).map((el) => el.offsetLeft + el.offsetWidth / 2); }; measure(); const observer = new ResizeObserver(measure); observer.observe(list); return () => observer.disconnect(); }, [items.length, iconSize]); // The background widens with the magnified icons by clipping a wider layer. const pad = iconSize * (magnification - 1) * 1.6; const inset = useTransform([pointerX, hover], ([x, h]: number[]) => { if (reduceMotion) return pad; let total = 0; for (const c of centers.current) total += iconSize * (scaleOf(state, c, x, h) - 1); return Math.max(0, pad - total / 2); }); // clip-path cuts away borders and box-shadows, so the 1px border is a second clipped layer // peeking out around the fill, and the shadow is a drop-shadow on their shared wrapper. const borderClip = useTransform(inset, (i) => `inset(0px ${i}px round 18px)`); const fillClip = useTransform(inset, (i) => `inset(1px ${i + 1}px round 17px)`); function onKeyDown(event: KeyboardEvent<HTMLDivElement>) { const moves: Record<string, number> = { ArrowRight: (focusIndex + 1) % items.length, ArrowLeft: (focusIndex - 1 + items.length) % items.length, Home: 0, End: items.length - 1, }; if (!(event.key in moves)) return; event.preventDefault(); itemRefs.current[moves[event.key]]?.focus(); } return ( <div className={cn("relative inline-flex", className)} {...props}> <div aria-hidden="true" className="pointer-events-none absolute inset-y-0" style={{ left: -pad, right: -pad, filter: "drop-shadow(0 10px 24px rgb(0 0 0 / 0.08)) drop-shadow(0 2px 6px rgb(0 0 0 / 0.05))" }} > <motion.div className="absolute inset-0 bg-border" style={{ clipPath: borderClip }} /> <motion.div className="absolute inset-0 bg-background" style={{ clipPath: fillClip }} /> </div> {/* A toolbar of buttons (not a list: the toolbar role would leave list items orphaned). */} <div ref={listRef} role="toolbar" aria-label={ariaLabel} aria-orientation="horizontal" onKeyDown={onKeyDown} onPointerMove={(event) => { const list = listRef.current; if (event.pointerType !== "mouse" || !list) return; // Centers are layout px; divide out any ancestor scale so the two agree. const rect = list.getBoundingClientRect(); pointerX.set(((event.clientX - rect.left) * list.offsetWidth) / (rect.width || 1)); hoverTarget.set(1); }} onPointerLeave={() => hoverTarget.set(0)} className="relative flex items-end px-2.5 pt-2.5 pb-2.5" style={{ gap: GAP }} > {items.map((item, index) => ( <DockIcon key={item.id} item={item} index={index} state={state} focusable={index === focusIndex} itemRef={(node) => { itemRefs.current[index] = node; }} onFocusItem={(i) => { setFocusIndex(i); // Keyboard focus magnifies the focused icon as if the cursor were over it. pointerX.set(centers.current[i] ?? 0); hoverTarget.set(1); }} onBlurItem={() => { if (!listRef.current?.matches(":hover")) hoverTarget.set(0); }} /> ))} </div> </div> ); }
Usage
import { Dock } from "@/components/ui/dock/dock";
export function AppDock() {
return (
<Dock
items={[
{ id: "files", label: "Files", icon: <FolderIcon />, href: "/files" },
{ id: "mail", label: "Mail", icon: <MailIcon />, onClick: openMail, active: true },
]}
/>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
items* | DockItem[] | – | The dock items, in order. |
iconSize | number | 48 | Resting icon size in px. |
magnification | number | 1.7 | Scale of the icon right under the cursor. |
distance | number | 140 | Distance in px over which neighbours also grow. |
Also accepts every native <div> attribute, including ref and className.
- Magnification is transform-only: icons scale from the bottom, and neighbours get computed x offsets so they spread apart without animating width.
- The background widens by animating clip-path on a wider layer, so its rounded corners never stretch.
- Give the dock room above it; magnified icons and tooltips extend upward.
Accessibility
- The dock is a toolbar with one tab stop; arrow keys, Home and End move between items, and each item is a labelled button or link.
- Keyboard focus magnifies the focused icon and shows its tooltip, just as hovering does.
- Magnification only follows a mouse; touch and pen users get a regular row of buttons.
- With reduced motion enabled, icons stay their resting size and the launch bounce is off; tooltips still appear.