Skip to content
jjswnth/ui

Navigation

Dock

A macOS-style dock whose icons magnify as the cursor sweeps past them.

Installation

  1. 1. Install the dependencies.

    terminal
    npm install motion clsx tailwind-merge
  2. 2. Add the cn helper, if you don't already have it.

    lib/utils.ts
    import { clsx, type ClassValue } from "clsx";
    import { twMerge } from "tailwind-merge";
    
    export function cn(...inputs: ClassValue[]) {
      return twMerge(clsx(inputs));
    }
  3. 3. Copy the component into your project.

    components/ui/dock.tsx
    "use client";
    
    import {
      motion,
      useMotionValue,
      useSpring,
      useTransform,
      type MotionValue,
    } from "motion/react";
    import {
      Children,
      cloneElement,
      isValidElement,
      useRef,
      type ReactElement,
      type ReactNode,
    } from "react";
    import { cn } from "@/lib/utils";
    
    type DockProps = {
      children: ReactNode;
      className?: string;
      /** Peak size of a hovered item, in pixels. */
      magnification?: number;
      /** How far from the cursor the magnification falls off, in pixels. */
      distance?: number;
      /** Resting size of an item, in pixels. */
      baseSize?: number;
    };
    
    export function Dock({
      children,
      className,
      magnification = 68,
      distance = 130,
      baseSize = 44,
    }: DockProps) {
      const mouseX = useMotionValue(Number.POSITIVE_INFINITY);
    
      return (
        <motion.div
          onMouseMove={(event) => mouseX.set(event.clientX)}
          onMouseLeave={() => mouseX.set(Number.POSITIVE_INFINITY)}
          className={cn(
            "mx-auto flex h-16 items-end gap-3 rounded-2xl border border-line/80 bg-surface/70 px-3 pb-2.5 backdrop-blur-xl",
            "shadow-[0_16px_40px_-18px_rgb(0_0_0/0.45)]",
            className,
          )}
        >
          {Children.map(children, (child) =>
            isValidElement<DockItemProps>(child)
              ? cloneElement(child as ReactElement<DockItemProps>, {
                  mouseX,
                  magnification,
                  distance,
                  baseSize,
                })
              : child,
          )}
        </motion.div>
      );
    }
    
    type DockItemProps = {
      children: ReactNode;
      label?: string;
      onClick?: () => void;
      className?: string;
      mouseX?: MotionValue<number>;
      magnification?: number;
      distance?: number;
      baseSize?: number;
    };
    
    export function DockItem({
      children,
      label,
      onClick,
      className,
      mouseX,
      magnification = 68,
      distance = 130,
      baseSize = 44,
    }: DockItemProps) {
      const ref = useRef<HTMLButtonElement>(null);
      const fallback = useMotionValue(Number.POSITIVE_INFINITY);
      const pointerX = mouseX ?? fallback;
    
      const distanceFromCenter = useTransform(pointerX, (value: number) => {
        const bounds = ref.current?.getBoundingClientRect();
        if (!bounds) return Number.POSITIVE_INFINITY;
        return value - bounds.x - bounds.width / 2;
      });
    
      const targetSize = useTransform(
        distanceFromCenter,
        [-distance, 0, distance],
        [baseSize, magnification, baseSize],
      );
    
      const size = useSpring(targetSize, {
        mass: 0.1,
        stiffness: 170,
        damping: 14,
      });
    
      return (
        <motion.button
          ref={ref}
          type="button"
          onClick={onClick}
          aria-label={label}
          style={{ width: size, height: size }}
          className={cn(
            "group relative grid shrink-0 place-items-center rounded-xl border border-line bg-elevated text-muted",
            "transition-colors hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
            className,
          )}
        >
          {label ? (
            <span
              className={cn(
                "pointer-events-none absolute -top-9 rounded-md border border-line bg-surface px-2 py-1 text-[11px] font-medium text-ink",
                "opacity-0 transition-opacity duration-150 group-hover:opacity-100",
              )}
            >
              {label}
            </span>
          ) : null}
          {children}
        </motion.button>
      );
    }

Props

PropTypeDefaultDescription
childrenReactNodeDockItem elements.
magnificationnumber68Peak size of a hovered item, in pixels.
distancenumber130Falloff distance from the cursor, in pixels.
baseSizenumber44Resting size of an item, in pixels.
classNamestringMerged onto the dock container.