Navigation

Dock

Bottom app dock whose icons magnify with distance falloff on pointer proximity — transform-only springs, plain taps on touch.

Install

npx shadcn@latest add @paragon/dock

dock.tsx

"use client";

import * as React from "react";
import {
  motion,
  useMotionValue,
  useSpring,
  useTransform,
  useReducedMotion,
  type MotionValue,
} from "motion/react";
import { cn } from "@/lib/utils";

interface DockContextValue {
  mouseX: MotionValue<number>;
  magnification: number;
  distance: number;
  enabled: boolean;
}

const DockContext = React.createContext<DockContextValue | null>(null);

function useDock() {
  const context = React.useContext(DockContext);
  if (!context) {
    throw new Error("DockItem must be used within <Dock>");
  }
  return context;
}

export interface DockProps extends React.ComponentProps<"div"> {
  /** Peak scale of the item directly under the pointer. */
  magnification?: number;
  /** Pixel radius of the magnification falloff around the pointer. */
  distance?: number;
  /** Disables pointer magnification entirely. */
  static?: boolean;
}

/**
 * Bottom app dock. Items magnify with distance falloff as the pointer
 * approaches — transform-only scaling driven by motion springs, so layout
 * never shifts. Magnification is gated to fine pointers (touch gets plain
 * taps) and disabled under reduced motion. Arrow keys move focus between
 * items; tooltips appear above on hover and keyboard focus.
 */
export function Dock({
  magnification = 1.5,
  distance = 140,
  static: isStatic = false,
  className,
  children,
  ...props
}: DockProps) {
  const mouseX = useMotionValue(Infinity);
  const reducedMotion = useReducedMotion();
  const [finePointer, setFinePointer] = React.useState(false);

  React.useEffect(() => {
    const query = window.matchMedia("(hover: hover) and (pointer: fine)");
    setFinePointer(query.matches);
    const onChange = (event: MediaQueryListEvent) =>
      setFinePointer(event.matches);
    query.addEventListener("change", onChange);
    return () => query.removeEventListener("change", onChange);
  }, []);

  const enabled = finePointer && !reducedMotion && !isStatic;

  const contextValue = React.useMemo(
    () => ({ mouseX, magnification, distance, enabled }),
    [mouseX, magnification, distance, enabled],
  );

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
    const buttons = Array.from(
      event.currentTarget.querySelectorAll<HTMLButtonElement>(
        "button:not(:disabled)",
      ),
    );
    const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
    if (index === -1) return;
    event.preventDefault();
    const step = event.key === "ArrowRight" ? 1 : -1;
    buttons[(index + step + buttons.length) % buttons.length]?.focus();
  };

  return (
    <div
      role="toolbar"
      aria-orientation="horizontal"
      data-slot="dock"
      onMouseMove={(event) => enabled && mouseX.set(event.clientX)}
      onMouseLeave={() => mouseX.set(Infinity)}
      onKeyDown={onKeyDown}
      className={cn(
        "flex w-fit items-end gap-2 rounded-[20px] bg-card/80 p-2 shadow-border backdrop-blur-md",
        className,
      )}
      {...props}
    >
      <DockContext.Provider value={contextValue}>
        {children}
      </DockContext.Provider>
    </div>
  );
}

export interface DockItemProps
  extends Omit<
    React.ComponentProps<"button">,
    "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"
  > {
  /** Accessible name, shown in the tooltip above the item. */
  label: string;
  /** Marks the item with a small running-indicator dot. */
  active?: boolean;
}

export function DockItem({
  label,
  active = false,
  className,
  children,
  ...props
}: DockItemProps) {
  const { mouseX, magnification, distance, enabled } = useDock();
  const ref = React.useRef<HTMLButtonElement>(null);

  const distanceFromPointer = useTransform(mouseX, (x) => {
    const bounds = ref.current?.getBoundingClientRect();
    if (!bounds) return Infinity;
    return x - bounds.x - bounds.width / 2;
  });
  const targetScale = useTransform(
    distanceFromPointer,
    [-distance, 0, distance],
    [1, magnification, 1],
  );
  const scale = useSpring(targetScale, {
    mass: 0.1,
    stiffness: 180,
    damping: 14,
  });

  return (
    <div className="group relative">
      <motion.button
        ref={ref}
        type="button"
        aria-label={label}
        style={{ scale: enabled ? scale : 1 }}
        className={cn(
          "pressable flex size-10 origin-bottom items-center justify-center rounded-xl bg-card text-foreground/80 shadow-border transition-[background-color,box-shadow,color] duration-150 ease-out hover:text-foreground hover:shadow-border-hover disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-5",
          className,
        )}
        {...props}
      >
        {children}
      </motion.button>
      <span
        role="presentation"
        aria-hidden
        className="pointer-events-none absolute -top-10 left-1/2 -translate-x-1/2 rounded-md bg-popover px-2 py-1 text-xs font-medium whitespace-nowrap text-popover-foreground opacity-0 shadow-overlay transition-[opacity,translate] duration-150 ease-out group-has-[:focus-visible]:-translate-y-0.5 group-has-[:focus-visible]:opacity-100 [@media(hover:hover)_and_(pointer:fine)]:group-hover:-translate-y-0.5 [@media(hover:hover)_and_(pointer:fine)]:group-hover:opacity-100"
      >
        {label}
      </span>
      {active && (
        <span
          aria-hidden
          className="absolute -bottom-1.5 left-1/2 size-1 -translate-x-1/2 rounded-full bg-foreground/60"
        />
      )}
    </div>
  );
}

export function DockSeparator({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      role="separator"
      aria-orientation="vertical"
      className={cn("mx-0.5 h-8 w-px self-center bg-border", className)}
      {...props}
    />
  );
}