Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

Fisheye Dock

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

MSource LandMellow UI

Why it stands out

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

Prompt

You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11 (install: `pnpm add motion`)

If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.

Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.

Copy-paste these files:

File location: components/fisheye-dock-demo.tsx

File content: "use client";

import React, { useEffect, useState } 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",
  },
];


/** The docs preview box is ~340px wide on phones — shrink to fit it. */
function useNarrow() {
  const [narrow, setNarrow] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(max-width: 640px)");
    const sync = () => setNarrow(mq.matches);
    sync();
    mq.addEventListener("change", sync);
    return () => mq.removeEventListener("change", sync);
  }, []);
  return narrow;
}

export default function FisheyeDockDemo() {
  const narrow = useNarrow();

  return (
    <div className="flex flex-col items-center gap-6 p-4 sm:gap-10 sm:p-8">
      <FisheyeDock
        items={items}
        size={narrow ? 36 : 44}
        magnification={narrow ? 60 : 76}
        distance={narrow ? 100 : 140}
      />
      <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>
  );
}



File location: components/mellow/fisheye-dock.tsx

File content: "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;



Usage notes:
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.

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI