Velora UI
All components

Dock

macOS-style dock with cursor magnification.

1.0 KB gzippedDeps: motion/reactReduced-motion safeBase UI & Radix compatible

Installation

npx shadcn@latest add https://velora.colorlib.com/r/dock.json

Installs with: motion

Source

"use client";

import { createContext, useContext, useRef } from "react";
import {
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  type MotionValue,
} from "motion/react";

import { cn } from "@/lib/utils";

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

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

interface DockProps {
  children: React.ReactNode;
  className?: string;
  /** Icon size at rest, px */
  baseSize?: number;
  /** Icon size under the cursor, px */
  magnification?: number;
  /** Cursor influence radius, px */
  distance?: number;
}

export function Dock({
  children,
  className,
  baseSize = 40,
  magnification = 64,
  distance = 140,
}: DockProps) {
  const mouseX = useMotionValue(Infinity);

  return (
    <DockContext.Provider value={{ mouseX, baseSize, magnification, distance }}>
      <motion.div
        data-slot="dock"
        onMouseMove={(e) => mouseX.set(e.clientX)}
        onMouseLeave={() => mouseX.set(Infinity)}
        className={cn(
          "mx-auto flex h-16 w-fit items-end gap-2 rounded-2xl border bg-card/70 px-3 pb-2 backdrop-blur-xl",
          className
        )}
      >
        {children}
      </motion.div>
    </DockContext.Provider>
  );
}

interface DockIconProps {
  children: React.ReactNode;
  className?: string;
  label?: string;
}

export function DockIcon({ children, className, label }: DockIconProps) {
  const ref = useRef<HTMLDivElement>(null);
  const reducedMotion = useReducedMotion();
  const fallbackX = useMotionValue(Infinity);
  const ctx = useContext(DockContext);
  const mouseX = ctx?.mouseX ?? fallbackX;
  const baseSize = ctx?.baseSize ?? 40;
  const magnification = ctx?.magnification ?? 64;
  const distance = ctx?.distance ?? 140;

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

  const sizeTarget = useTransform(
    distanceFromCursor,
    [-distance, 0, distance],
    [baseSize, magnification, baseSize]
  );
  const size = useSpring(sizeTarget, {
    mass: 0.1,
    stiffness: 160,
    damping: 13,
  });

  return (
    <motion.div
      ref={ref}
      data-slot="dock-icon"
      aria-label={label}
      style={
        reducedMotion
          ? { width: baseSize, height: baseSize }
          : { width: size, height: size }
      }
      className={cn(
        "flex aspect-square cursor-pointer items-center justify-center rounded-full bg-muted text-muted-foreground transition-colors hover:text-foreground",
        className
      )}
    >
      {children}
    </motion.div>
  );
}