Back

Dock Menu

Install dependencies
npm i framer-motion clsx tailwind-merge lucide-react
utils/cn.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
DockMenu.tsx
// @NOTE: in case you are using Next.js
"use client";

import { ElementRef, HTMLAttributes, useRef } from "react";

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

import { AlbumIcon, HomeIcon, MonitorIcon } from "lucide-react";

import {
  MotionValue,
  motion,
  useMotionValue,
  useSpring,
  useTransform,
} from "framer-motion";

type DockItem = {
  id: string;
  icon?: JSX.Element;
};

type DockContainerProps = {
  side?: "top" | "bottom";
  items: DockItem[];
} & HTMLAttributes<HTMLDivElement>;

function Dock({
  side = "bottom",
  className,
  items,
  ...props
}: DockContainerProps) {
  const mouseX = useMotionValue(Infinity);
  const containerX = useMotionValue(0);

  const containerRef = useRef<ElementRef<"div">>(null);

  return (
    <div
      {...props}
      className={cn(side === "top" ? "top-4" : "bottom-4", className)}
    >
      <motion.div
        ref={containerRef}
        className="h-16 items-end gap-4 rounded-full bg-neutral-950 border border-neutral-800 px-3 pb-2 flex shadow-inner shadow-neutral-300/5"
        onMouseLeave={() => mouseX.set(Infinity)}
        onMouseMove={(e) => {
          const rect = containerRef.current?.getBoundingClientRect();

          if (rect) {
            mouseX.set(e.clientX - rect.left);
            containerX.set(rect.x);
          }
        }}
      >
        {items.map((item) => (
          <DockItem key={item.id} containerX={containerX} mouseX={mouseX}>
            {item.icon}
          </DockItem>
        ))}
      </motion.div>
    </div>
  );
}

interface DockItemProps extends HTMLAttributes<HTMLElement> {
  mouseX: MotionValue<number>;
  containerX: MotionValue<number>;
}

function DockItem({ children, containerX, mouseX }: DockItemProps) {
  const itemRef = useRef<ElementRef<"div">>(null);

  const distance = useTransform(mouseX, (val) => {
    const bounds = itemRef.current?.getBoundingClientRect() ?? {
      x: 0,
      width: 0,
      left: 0,
    };

    const XDiffToContainerX = bounds?.x - containerX.get();

    return val - bounds?.width / 2 - XDiffToContainerX;
  });

  const widthSync = useTransform(distance, [-125, 0, 125], [44, 85, 44]);
  const width = useSpring(widthSync);

  return (
    <motion.div
      role="button"
      ref={itemRef}
      className="group p-2 flex aspect-square items-center justify-center overflow-hidden rounded-full transition active:-translate-y-10 bg-neutral-950 border-neutral-800 border shadow-inner shadow-neutral-300/20 active:duration-1000 active:ease-out text-neutral-400 hover:text-white duration-500"
      style={{
        width,
      }}
    >
      {children}
    </motion.div>
  );
}

export function DockMenu() {
  const items = [
    { id: "first-id", icon: <HomeIcon size={32} /> },
    { id: "second-id", icon: <AlbumIcon size={32} /> },
    { id: "fourth-id", icon: <MonitorIcon size={32} /> },
  ];

  return <Dock items={items} />;
}