liten

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.

Terminal
bun add motion
components/ui/dock-menu.tsx
'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

Example.tsx
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

PropTypeDefaultDescription
itemsDockItem[]-Tiles (DockApp) or separators ({ separator: true }).
baseSizenumber44Resting tile size in px.
magnificationnumber74Tile size at the pointer's focus in px.
rangenumber130Pointer influence radius in px.
accentstring"#f0883e"Accent for focus rings and the running dot.
classNamestring-Forwarded to the <nav>.

DockApp is { label, icon, href?, onSelect?, active? }.

On this page0%