Dock Menu
A macOS-style dock where tiles magnify along a cosine bell as the pointer sweeps past, with a launch bounce and a running-app indicator.
Every tile reads its distance from a single shared pointer position and magnifies along a cosine-bell falloff, so neighbours part cleanly instead of scaling in place. A click launches a decaying double bounce, and active apps carry a small accent capsule that stretches on hover. Built with Motion.
Installation
Complete the shared Setup first, then copy the component into
components/ui/dock-menu.tsx.
bun add motion'use client';import * as React from 'react';import { animate, AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue,} from 'motion/react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const MAGNIFY_SPRING = { mass: 0.1, stiffness: 170, damping: 14 } as const;const FOCUS = 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--dock-accent)]';export type DockApp = { label: string; icon: React.ReactNode; href?: string; onSelect?: () => void; active?: boolean;};export type DockItem = DockApp | { separator: true };function isSeparator(item: DockItem): item is { separator: true } { return 'separator' in item;}export type DockMenuProps = { items: DockItem[]; baseSize?: number; magnification?: number; range?: number; accent?: string; className?: string;};export function DockMenu({ items, baseSize = 44, magnification = 74, range = 130, accent = '#f0883e', className,}: DockMenuProps) { const mouseX = useMotionValue(Infinity); const navRef = React.useRef<HTMLElement>(null); const onKeyDown = (e: React.KeyboardEvent) => { if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; const btns = Array.from( navRef.current?.querySelectorAll<HTMLElement>('[data-dock-btn]') ?? [], ); const here = btns.indexOf(document.activeElement as HTMLElement); if (here === -1) return; e.preventDefault(); const next = e.key === 'ArrowRight' ? here + 1 : here - 1; btns[(next + btns.length) % btns.length]?.focus(); }; const vars = { '--dock-accent': accent } as React.CSSProperties; return ( <nav ref={navRef} style={vars} onKeyDown={onKeyDown} onPointerMove={(e) => mouseX.set(e.clientX)} onPointerLeave={() => mouseX.set(Infinity)} className={cn('inline-flex', className)} > <ul style={{ height: baseSize + 28 }} className={cn( 'bloom-edge flex items-end gap-1.5 rounded-[22px] px-2.5 pb-2', 'bg-neutral-200/55 backdrop-blur-xl dark:bg-[#141415]/65', 'shadow-[0_1px_1px_0_rgba(0,0,0,0.05),0_10px_24px_-14px_rgba(0,0,0,0.22)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.07),0_1px_2px_0_rgba(0,0,0,0.5),0_14px_30px_-14px_rgba(0,0,0,0.7)]', )} > {items.map((item, i) => isSeparator(item) ? ( <li key={`sep-${i}`} aria-hidden className="mx-1 h-8 w-px self-center bg-gradient-to-b from-black/[0.03] via-black/[0.1] to-black/[0.03] dark:from-white/[0.03] dark:via-white/[0.12] dark:to-white/[0.03]" /> ) : ( <DockTile key={item.label} app={item} mouseX={mouseX} baseSize={baseSize} magnification={magnification} range={range} /> ), )} </ul> </nav> );}function DockTile({ app, mouseX, baseSize, magnification, range,}: { app: DockApp; mouseX: MotionValue<number>; baseSize: number; magnification: number; range: number;}) { const reduce = useReducedMotion(); const ref = React.useRef<HTMLButtonElement>(null); const [open, setOpen] = React.useState(false); const distance = useTransform(mouseX, (x) => { const b = ref.current?.getBoundingClientRect(); const center = b ? b.x + b.width / 2 : 0; return x - center; }); const [bellIn, bellOut] = React.useMemo(() => { const steps = 6; const input: number[] = []; const output: number[] = []; for (let i = -steps; i <= steps; i++) { const t = i / steps; input.push(t * range); const f = Math.cos((Math.PI / 2) * t) ** 2; output.push(baseSize + (magnification - baseSize) * f); } return [input, output]; }, [baseSize, magnification, range]); const sizeTarget = useTransform(distance, bellIn, bellOut, { clamp: true }); const size = useSpring(sizeTarget, MAGNIFY_SPRING); const dim = reduce ? baseSize : size; const glyphMotion = useTransform(size, (s) => s * 0.46); const glyph = reduce ? baseSize * 0.46 : glyphMotion; const bounceY = useMotionValue(0); const doBounce = () => { if (reduce) return; animate(bounceY, [0, -0.36 * baseSize, 0, -0.13 * baseSize, 0], { duration: 0.62, times: [0, 0.28, 0.55, 0.78, 1], ease: [0.34, 0, 0.2, 1], }); }; const Tile = app.href ? motion.a : motion.button; return ( <li className="relative flex flex-col items-center"> <AnimatePresence> {open && ( <motion.span key="label" initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: 'blur(4px)' }} animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }} exit={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: 'blur(4px)' }} transition={{ duration: 0.16, ease: EASE_OUT }} className={cn( 'bloom-edge pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 -translate-x-1/2 whitespace-nowrap rounded-lg px-2 py-1', 'text-[12px] font-medium tracking-[-0.01em]', 'bg-gradient-to-b from-white to-[#f4f4f5] text-neutral-900', 'dark:from-[#272727] dark:to-[#191919] dark:text-white', 'shadow-[0_1px_2px_0_rgba(0,0,0,0.05),0_6px_16px_-6px_rgba(0,0,0,0.2)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.06),0_6px_16px_-10px_rgba(0,0,0,0.45)]', )} > {app.label} </motion.span> )} </AnimatePresence> <Tile ref={ref as never} href={app.href} type={app.href ? undefined : 'button'} data-dock-btn aria-label={app.label} onClick={() => { doBounce(); app.onSelect?.(); }} onPointerEnter={() => setOpen(true)} onPointerLeave={() => setOpen(false)} onFocus={() => setOpen(true)} onBlur={() => setOpen(false)} whileTap={reduce ? undefined : { scale: 0.92 }} style={{ width: dim, height: dim, y: reduce ? 0 : bounceY, originY: 1 }} className={cn( 'bloom-edge grid place-items-center rounded-[24%] outline-none', 'bg-gradient-to-b from-white to-[#e6e6ea] text-neutral-800', 'dark:from-[#343436] dark:to-[#232325] dark:text-neutral-100', 'shadow-[inset_0_1px_0_0_rgba(255,255,255,1),0_1px_1px_0_rgba(0,0,0,0.04),0_3px_8px_-4px_rgba(0,0,0,0.18)]', 'dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.1),0_1px_2px_0_rgba(0,0,0,0.5),0_4px_10px_-4px_rgba(0,0,0,0.55)]', FOCUS, )} > <motion.span className="grid place-items-center [&_svg]:h-[1em] [&_svg]:w-[1em]" style={{ fontSize: glyph }} aria-hidden > {app.icon} </motion.span> </Tile> <span aria-hidden className="mt-[6px] flex h-[3px] items-center justify-center"> {app.active && ( <motion.span animate={{ width: reduce ? 8 : open ? 15 : 5 }} transition={reduce ? { duration: 0 } : { duration: 0.22, ease: EASE_OUT }} className="h-[3px] rounded-full" style={{ backgroundColor: 'var(--dock-accent)', boxShadow: 'inset 0 0.5px 0.5px 0 rgba(255,255,255,0.55), 0 0 0 0.5px rgba(0,0,0,0.06)', }} /> )} </span> </li> );}Usage
import { Folder, Mail, Settings } from 'lucide-react';
import { DockMenu, type DockItem } from '@/components/ui/dock-menu';
const items: DockItem[] = [
{ label: 'Finder', icon: <Folder strokeWidth={1.75} />, active: true },
{ label: 'Mail', icon: <Mail strokeWidth={1.75} /> },
{ separator: true },
{ label: 'Settings', icon: <Settings strokeWidth={1.75} /> },
];
export default function Example() {
return <DockMenu items={items} />;
}Examples
A smaller dock with a lower magnification and a cyan accent.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | DockItem[] | - | Tiles (DockApp) or separators ({ separator: true }). |
baseSize | number | 44 | Resting tile size in px. |
magnification | number | 74 | Tile size at the pointer's focus in px. |
range | number | 130 | Pointer influence radius in px. |
accent | string | "#f0883e" | Accent for focus rings and the running dot. |
className | string | - | Forwarded to the <nav>. |
DockApp is { label, icon, href?, onSelect?, active? }.