niceui

Dock

A macOS-style dock with distance-based magnification, tooltips, launch bounce and open dots.

ReactTailwind CSSTypeScriptComponentsMIT · Free
Live preview

Installation

01

Install with the shadcn CLI. The NiceUI theme comes along the first time.

$ npx shadcn@latest add https://niceui.dev/r/components-dock.json
02

Or copy the source below. Dependencies: lucide-react.

Source

components/niceui/ui/motion/Dock.tsx
import { useRef, useState, type ComponentType, type MouseEvent } from 'react';
import { Calendar, Camera, Compass, Folder, Mail, MessageSquare, Music, Settings, Terminal, Trash2 } from 'lucide-react';
import { cn } from '@/lib/cn';

export type DockItem = { id: string; label: string; icon: ComponentType<{ className?: string }>; tint: string };

export type DockProps = {
  items: DockItem[];
  baseSize?: number;
  maxSize?: number;
  distance?: number;
  defaultOpen?: string[];
};

export function Dock({ items, baseSize = 44, maxSize = 72, distance = 140, defaultOpen = [] }: DockProps) {
  const refs = useRef<Array<HTMLButtonElement | null>>([]);
  const [sizes, setSizes] = useState<number[]>(() => items.map(() => baseSize));
  const [hovering, setHovering] = useState(false);
  const [hover, setHover] = useState<string | null>(null);
  const [open, setOpen] = useState<string[]>(defaultOpen);
  const [bouncing, setBouncing] = useState<string | null>(null);

  const onMove = (e: MouseEvent) => {
    setHovering(true);
    setSizes(
      refs.current.map((el) => {
        if (!el) return baseSize;
        const r = el.getBoundingClientRect();
        const d = Math.abs(e.clientX - (r.left + r.width / 2));
        if (d > distance) return baseSize;
        const k = Math.cos((d / distance) * (Math.PI / 2)); // 1 at center → 0 at edge
        return baseSize + (maxSize - baseSize) * k * k;
      }),
    );
  };
  const onLeave = () => {
    setHovering(false);
    setHover(null);
    setSizes(items.map(() => baseSize));
  };

  const launch = (id: string) => {
    if (open.includes(id)) return setOpen((o) => o.filter((x) => x !== id));
    setBouncing(id);
    window.setTimeout(() => {
      setBouncing(null);
      setOpen((o) => [...o, id]);
    }, 900);
  };

  return (
    <div
      onMouseMove={onMove}
      onMouseLeave={onLeave}
      className="mx-auto flex w-max items-end gap-2 rounded-[22px] bg-ink-900/70 px-2.5 pb-2 pt-2 shadow-pop backdrop-blur-xl"
      style={{ height: baseSize + 26 }}
    >
      <style>{'@keyframes dock-bounce{0%,100%{transform:translateY(0)}25%{transform:translateY(-22px)}50%{transform:translateY(0)}70%{transform:translateY(-10px)}}'}</style>
      {items.map((item, i) => {
        const size = sizes[i] ?? baseSize;
        return (
          <div key={item.id} className="relative flex flex-col items-center">
            <span
              className={cn(
                'pointer-events-none absolute -top-10 whitespace-nowrap rounded-lg bg-ink-800 px-2.5 py-1 text-xs font-medium text-ink-50 shadow-pop transition-all duration-200 ease-spring',
                hover === item.id ? 'translate-y-0 scale-100 opacity-100' : 'translate-y-1 scale-90 opacity-0',
              )}
            >
              {item.label}
            </span>
            <button
              ref={(el) => (refs.current[i] = el)}
              aria-label={item.label}
              onMouseEnter={() => setHover(item.id)}
              onClick={() => launch(item.id)}
              className={cn(
                'focus-ring flex items-center justify-center rounded-[28%] shadow-[inset_0_1px_0_rgba(255,255,255,0.2),0_4px_12px_rgba(0,0,0,0.4)] active:brightness-75',
                item.tint,
                hovering ? 'transition-[width,height] duration-100 ease-out' : 'transition-[width,height] duration-500 ease-spring',
              )}
              style={{ width: size, height: size, animation: bouncing === item.id ? 'dock-bounce 900ms ease-in-out' : undefined }}
            >
              <item.icon className="h-[46%] w-[46%] text-white drop-shadow" />
            </button>
            <span className={cn('mt-1 h-1 w-1 rounded-full bg-ink-200 transition-all duration-300', open.includes(item.id) ? 'scale-100 opacity-100' : 'scale-0 opacity-0')} />
          </div>
        );
      })}
    </div>
  );
}

export const DOCK_ITEMS: DockItem[] = [
  { id: 'finder', label: 'Finder', icon: Folder, tint: 'bg-gradient-to-b from-sky-400 to-brand-600' },
  { id: 'safari', label: 'Safari', icon: Compass, tint: 'bg-gradient-to-b from-sky-300 to-sky-600' },
  { id: 'mail', label: 'Mail', icon: Mail, tint: 'bg-gradient-to-b from-brand-400 to-brand-700' },
  { id: 'messages', label: 'Messages', icon: MessageSquare, tint: 'bg-gradient-to-b from-emerald-400 to-emerald-600' },
  { id: 'calendar', label: 'Calendar', icon: Calendar, tint: 'bg-gradient-to-b from-rose-400 to-rose-600' },
  { id: 'music', label: 'Music', icon: Music, tint: 'bg-gradient-to-b from-rose-400 to-amber-500' },
  { id: 'camera', label: 'Photo Booth', icon: Camera, tint: 'bg-gradient-to-b from-amber-300 to-amber-600' },
  { id: 'terminal', label: 'Terminal', icon: Terminal, tint: 'bg-gradient-to-b from-ink-600 to-ink-800' },
  { id: 'settings', label: 'Settings', icon: Settings, tint: 'bg-gradient-to-b from-ink-400 to-ink-600' },
  { id: 'trash', label: 'Trash', icon: Trash2, tint: 'bg-gradient-to-b from-ink-500 to-ink-700' },
];

export function DockDemo() {
  return (
    <div className="flex h-[220px] w-full flex-col items-center justify-between pb-2">
      <p className="text-xs text-ink-500">Hover to magnify · click to launch</p>
      <Dock items={DOCK_ITEMS} defaultOpen={['finder', 'safari', 'messages']} />
    </div>
  );
}