# Fisheye Dock (`fisheye-dock`)

> A macOS-style dock — icons swell under the cursor with a fisheye falloff, neighbours rising in sympathy, labels popping above on hover.

- **Docs:** https://www.mellowui.com/components/fisheye-dock
- **Markdown:** https://www.mellowui.com/components/fisheye-dock.md
- **Registry:** https://www.mellowui.com/r/fisheye-dock.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/fisheye-dock
- **Categories:** interactive, navigation, animation
- **Dependencies:** motion

## AI prompt

Add a FisheyeDock component from the mellow library — a macOS-style dock nav where icons magnify under the cursor with spring physics and a fisheye falloff to their neighbours. Each item shows a mono uppercase tooltip label on hover and an accent active dot that slides to the clicked item (set `active: true` on one item for the initial selection). Pass an `items` array of { icon, label, href?, onClick?, active? } — icons are ReactNodes (SVGs scale to fit). Tune size, magnification, and distance for the magnify feel. Magnification is disabled under prefers-reduced-motion.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/fisheye-dock.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `items` | `{ icon: ReactNode; label: string; href?: string; onClick?: () => void; active?: boolean }[]` | — | Dock items — icon, tooltip label, link or click handler, active dot. |
| `size` | `number` | `44` | Resting icon size in px. |
| `magnification` | `number` | `76` | Icon size directly under the cursor in px. |
| `distance` | `number` | `140` | Cursor influence radius in px — how far the fisheye reaches. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useRef, useState } from "react";
import {
  AnimatePresence,
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  type MotionValue,
} from "motion/react";

export interface FisheyeDockItem {
  icon: React.ReactNode;
  label: string;
  href?: string;
  onClick?: () => void;
  /** Show the active indicator dot under the icon. */
  active?: boolean;
}

export interface FisheyeDockProps {
  items: FisheyeDockItem[];
  /** Resting icon size in px. */
  size?: number;
  /** Icon size directly under the cursor in px. */
  magnification?: number;
  /** Cursor influence radius in px. */
  distance?: number;
  className?: string;
  style?: React.CSSProperties;
}

/**
 * A macOS-style dock — icons swell under the cursor with a fisheye falloff,
 * neighbours rising in sympathy, labels popping above on hover.
 */
export function FisheyeDock({
  items,
  size = 44,
  magnification = 76,
  distance = 140,
  className,
  style,
}: FisheyeDockProps) {
  const mouseX = useMotionValue(Infinity);
  const reduced = useReducedMotion();
  const [activeIndex, setActiveIndex] = useState(() =>
    Math.max(0, items.findIndex((item) => item.active))
  );

  return (
    <nav
      aria-label="Dock"
      onMouseMove={(e) => {
        if (!reduced) mouseX.set(e.clientX);
      }}
      onMouseLeave={() => mouseX.set(Infinity)}
      className={[
        "flex items-end gap-2 rounded-2xl border border-[var(--rule)] bg-[rgba(var(--background-rgb),0.7)] px-2.5 pb-2.5 backdrop-blur-md",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      style={{ height: size + 20, ...style }}
    >
      {items.map((item, i) => (
        <DockIcon
          key={i}
          item={item}
          active={i === activeIndex}
          onSelect={() => setActiveIndex(i)}
          mouseX={mouseX}
          size={size}
          magnification={magnification}
          distance={distance}
        />
      ))}
    </nav>
  );
}

function DockIcon({
  item,
  active,
  onSelect,
  mouseX,
  size,
  magnification,
  distance,
}: {
  item: FisheyeDockItem;
  active: boolean;
  onSelect: () => void;
  mouseX: MotionValue<number>;
  size: number;
  magnification: number;
  distance: number;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const [hovered, setHovered] = useState(false);

  const dist = useTransform(mouseX, (val: number) => {
    const bounds = ref.current?.getBoundingClientRect() ?? { x: 0, width: 0 };
    return val - bounds.x - bounds.width / 2;
  });

  const target = useTransform(
    dist,
    [-distance, 0, distance],
    [size, magnification, size]
  );
  const side = useSpring(target, { mass: 0.1, stiffness: 170, damping: 13 });

  const Comp: React.ElementType = item.href ? "a" : "button";

  return (
    <motion.div
      ref={ref}
      style={{ width: side, height: side }}
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      className="relative flex items-center justify-center"
    >
      <AnimatePresence>
        {hovered && (
          <motion.span
            initial={{ opacity: 0, y: 6, scale: 0.9 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 4, scale: 0.9 }}
            transition={{ type: "spring", stiffness: 350, damping: 25 }}
            style={{ x: "-50%" }}
            className="pointer-events-none absolute -top-9 left-1/2 rounded-md border border-[var(--rule)] bg-[var(--background)] px-2 py-1 [font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.12em] whitespace-nowrap text-[var(--ink)] uppercase"
          >
            {item.label}
          </motion.span>
        )}
      </AnimatePresence>

      <Comp
        href={item.href}
        type={item.href ? undefined : "button"}
        onClick={() => {
          onSelect();
          item.onClick?.();
        }}
        aria-label={item.label}
        aria-current={active ? "page" : undefined}
        className="flex h-full w-full cursor-pointer items-center justify-center rounded-xl border border-[rgba(var(--ink-rgb),0.1)] bg-[rgba(var(--ink-rgb),0.06)] text-[var(--ink)] transition-colors hover:bg-[rgba(var(--ink-rgb),0.1)] [&_svg]:h-[44%] [&_svg]:w-[44%]"
      >
        {item.icon}
      </Comp>

      {active && (
        <motion.span
          layoutId="fisheye-dock-active"
          aria-hidden="true"
          className="absolute -bottom-1.5 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-[oklch(0.65_0.25_250)]"
          transition={{ type: "spring", stiffness: 380, damping: 28 }}
        />
      )}
    </motion.div>
  );
}

export default FisheyeDock;

```

## Demo

```tsx
"use client";

import React from "react";
import { FisheyeDock } from "../mellow/fisheye-dock";

function Icon({ d }: { d: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.75"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      <path d={d} />
    </svg>
  );
}

const items = [
  {
    icon: <Icon d="M3 10.5 12 3l9 7.5M5 9.5V21h5v-6h4v6h5V9.5" />,
    label: "Home",
    active: true,
  },
  {
    icon: <Icon d="M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm10 2-4.35-4.35" />,
    label: "Search",
  },
  {
    icon: <Icon d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6Zm0 0v6h6M9 13h6M9 17h6" />,
    label: "Docs",
  },
  {
    icon: <Icon d="M9 18V5l12-2v13M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm12-2a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />,
    label: "Music",
  },
  {
    icon: <Icon d="M21 15l-5-5L5 21M3 3h18v18H3V3Zm6 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z" />,
    label: "Gallery",
  },
  {
    icon: <Icon d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7.4-3a7.4 7.4 0 0 0-.1-1.2l2-1.6-2-3.4-2.4 1a7.5 7.5 0 0 0-2-1.2L14.5 3h-5l-.4 2.6a7.5 7.5 0 0 0-2 1.2l-2.4-1-2 3.4 2 1.6a7.4 7.4 0 0 0 0 2.4l-2 1.6 2 3.4 2.4-1a7.5 7.5 0 0 0 2 1.2l.4 2.6h5l.4-2.6a7.5 7.5 0 0 0 2-1.2l2.4 1 2-3.4-2-1.6c.07-.4.1-.8.1-1.2Z" />,
    label: "Settings",
  },
];

export default function FisheyeDockDemo() {
  return (
    <div className="flex flex-col items-center gap-10 p-8">
      <FisheyeDock items={items} />
      <p className="text-[0.8125rem] text-[rgba(var(--ink-rgb),0.35)]">
        Sweep the cursor across the dock · click to select · hover for labels
      </p>
    </div>
  );
}

```
