p441z/Komponen/Dock
Desktop · components/mac/dock.tsx

Dock

Icons magnify near the pointer, with labels.

127 barisframer-motionGitHub ↗
Pasang lewat shadcn CLI

Baru pertama pakai kit ini? Baca panduan pemasangannya

Tentang

Dock — a desktop dock: icons grow as the pointer passes over them (magnification), the label floats above the hovered icon, and a small dot marks the active item.

Clicking an icon makes it hop twice, the second hop smaller — the way an app bounces while it opens (`animate-launch` in globals.css). Labels pop in on a spring. Reduced motion: no hop.

Works as real navigation: give items `href` (rendered as links) or `onClick`. Keyboard focus shows the label too. On touch screens there is no hover, so icons simply stay at base size.

Cara pakai

<Dock className="fixed bottom-4 left-1/2 -translate-x-1/2" items={[
  { label: "Home", icon: <HomeIcon />, href: "#top", active: true },
  { label: "Motion", icon: <SparkIcon />, href: "#motion" },
  { label: "GitHub", icon: <GitHubIcon />, href: "https://github.com/…" },
]} />

Ikut terpasang

Kode sumber

components/mac/dock.tsx · 127 baris
"use client";

import * as React from "react";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue } from "framer-motion";

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

/**
 * Dock — a desktop dock: icons grow as the pointer passes over them (magnification), the label
 * floats above the hovered icon, and a small dot marks the active item.
 *
 * Clicking an icon makes it hop twice, the second hop smaller — the way an app bounces while it
 * opens (`animate-launch` in globals.css). Labels pop in on a spring. Reduced motion: no hop.
 *
 * Works as real navigation: give items `href` (rendered as links) or `onClick`. Keyboard focus
 * shows the label too. On touch screens there is no hover, so icons simply stay at base size.
 *
 * USAGE
 *   <Dock className="fixed bottom-4 left-1/2 -translate-x-1/2" items={[
 *     { label: "Home", icon: <HomeIcon />, href: "#top", active: true },
 *     { label: "Motion", icon: <SparkIcon />, href: "#motion" },
 *     { label: "GitHub", icon: <GitHubIcon />, href: "https://github.com/…" },
 *   ]} />
 */

export type DockItem = {
  label: string;
  icon: React.ReactNode;
  href?: string;
  onClick?: () => void;
  active?: boolean;
  /** Draw a thin divider before this item. */
  divider?: boolean;
};

interface DockProps {
  items: DockItem[];
  className?: string;
  /** Base icon size (px). */
  size?: number;
  /** Size under the pointer (px). */
  magnified?: number;
  /** Pointer distance (px) over which magnification fades out. */
  reach?: number;
}

function Icon({ item, mouseX, size, magnified, reach }: { item: DockItem; mouseX: MotionValue<number>; size: number; magnified: number; reach: number }) {
  const ref = React.useRef<HTMLAnchorElement & HTMLButtonElement>(null);
  const [hover, setHover] = React.useState(false);
  const [hops, setHops] = React.useState(0);
  const dist = useTransform(mouseX, (x) => {
    const r = ref.current?.getBoundingClientRect();
    return r ? x - (r.left + r.width / 2) : Infinity;
  });
  const target = useTransform(dist, [-reach, 0, reach], [size, magnified, size]);
  const w = useSpring(target, { mass: 0.1, stiffness: 170, damping: 14 });

  const inner = (
    <>
      <AnimatePresence>
        {hover && (
          <motion.span
            initial={{ opacity: 0, y: 8, scale: 0.85 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 4, scale: 0.95, transition: { duration: 0.12 } }}
            transition={{ ...SPRING.pop, opacity: { duration: 0.12 } }}
            className="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-background/90 px-2 py-0.5 text-[12px] font-medium shadow-sm backdrop-blur"
          >
            {item.label}
          </motion.span>
        )}
      </AnimatePresence>
      {/* Re-keyed on every click so the launch hop restarts, like an app opening from the dock. */}
      <motion.span key={hops} style={{ width: w, height: w }} className={cn("grid place-items-center rounded-[22%] border border-border/70 bg-background shadow-[0_6px_14px_-6px_rgba(0,0,0,0.35)] motion-reduce:animate-none [&_svg]:size-[46%]", hops > 0 && "animate-launch")}>
        {item.icon}
      </motion.span>
      {item.active && <span className="absolute -bottom-1.5 left-1/2 size-1 -translate-x-1/2 animate-pop-in rounded-full bg-primary" aria-hidden />}
    </>
  );
  const common = {
    ref,
    "aria-label": item.label,
    "aria-current": item.active ? ("page" as const) : undefined,
    onMouseEnter: () => setHover(true),
    onMouseLeave: () => setHover(false),
    onFocus: () => setHover(true),
    onBlur: () => setHover(false),
    onClick: () => {
      setHops((n) => n + 1);
      item.onClick?.();
    },
    className: "relative flex flex-col items-center outline-hidden focus-visible:[&>span:last-of-type]:ring-1 focus-visible:[&>span:last-of-type]:ring-ring",
  };
  return item.href ? (
    <a href={item.href} {...common} target={item.href.startsWith("http") ? "_blank" : undefined} rel={item.href.startsWith("http") ? "noreferrer" : undefined}>
      {inner}
    </a>
  ) : (
    <button type="button" {...common}>
      {inner}
    </button>
  );
}

export default function Dock({ items, className, size = 44, magnified = 68, reach = 140 }: DockProps) {
  const mouseX = useMotionValue(Infinity);
  const reduce = useReducedMotion();
  return (
    <nav aria-label="Dock" className={cn("z-50 w-fit", className)}>
      <motion.div
        onMouseMove={(e) => !reduce && mouseX.set(e.clientX)}
        onMouseLeave={() => mouseX.set(Infinity)}
        className="flex items-end gap-2.5 rounded-2xl border border-white/40 bg-background/55 px-2.5 pb-2 pt-2 shadow-[0_20px_50px_-20px_rgba(0,0,0,0.45)] backdrop-blur-2xl backdrop-saturate-150 dark:border-white/10"
        style={{ height: size + 18 }}
      >
        {items.map((it) => (
          <React.Fragment key={it.label}>
            {it.divider && <span className="mx-0.5 h-[60%] w-px self-center bg-border" aria-hidden />}
            <Icon item={it} mouseX={mouseX} size={size} magnified={reduce ? size : magnified} reach={reach} />
          </React.Fragment>
        ))}
      </motion.div>
    </nav>
  );
}