Fazekit

Code

"use client";
import * as React from "react";
import {
  AnimatePresence,
  motion,
  useMotionValue,
  useReducedMotion,
  useSpring,
  useTransform,
  type MotionValue,
} from "motion/react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";

export interface DockItem {
  id: string;
  label: string;
  icon: LucideIcon;
  onClick?: () => void;
  href?: string;
  /** Shows the running dot below the icon. */
  active?: boolean;
  /** Tile colour classes, e.g. "bg-gradient-to-br from-sky-400 to-indigo-500 text-white". */
  className?: string;
  /** Draw a divider before this item. */
  separatorBefore?: boolean;
}

export interface DockMagnifyProps {
  items: DockItem[];
  /** Resting icon size in px. */
  size?: number;
  /** Size of the icon directly under the cursor. */
  magnification?: number;
  /** Cursor distance (px) over which neighbours are affected. */
  distance?: number;
  /** Accessible name of the toolbar. */
  label?: string;
  className?: string;
}

export function DockMagnify({
  items,
  size = 44,
  magnification = 72,
  distance = 140,
  label = "Dock",
  className,
}: DockMagnifyProps) {
  const reduce = useReducedMotion();
  // Horizontal cursor position; Infinity = nothing hovered.
  const mouseX = useMotionValue(Infinity);
  const btnRefs = React.useRef<(HTMLElement | null)[]>([]);
  const [rove, setRove] = React.useState(0);

  // Shrink the resting size so the dock always fits its container (e.g. phones).
  const barRef = React.useRef<HTMLDivElement>(null);
  const [fit, setFit] = React.useState(size);
  React.useEffect(() => {
    const parent = barRef.current?.parentElement;
    if (!parent) return;
    const separators = items.filter((i) => i.separatorBefore).length;
    const ro = new ResizeObserver(() => {
      const bar = barRef.current;
      if (!bar) return;
      const gap = parseFloat(getComputedStyle(bar).columnGap) || 8;
      const chrome = 22 + gap * (items.length - 1 + separators * 2) + separators;
      const ps = getComputedStyle(parent);
      const avail = parent.clientWidth - parseFloat(ps.paddingLeft) - parseFloat(ps.paddingRight);
      setFit(Math.max(24, Math.min(size, Math.floor((avail - chrome) / items.length))));
    });
    ro.observe(parent);
    return () => ro.disconnect();
  }, [items, size]);
  const base = fit;
  const peak = Math.max(base, magnification * (base / size));

  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const i = btnRefs.current.findIndex((el) => el === document.activeElement);
    if (i < 0) return;
    let next = -1;
    if (e.key === "ArrowRight") next = (i + 1) % items.length;
    else if (e.key === "ArrowLeft") next = (i - 1 + items.length) % items.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = items.length - 1;
    if (next < 0) return;
    e.preventDefault();
    btnRefs.current[next]?.focus();
  };

  return (
    <div
      ref={barRef}
      role="toolbar"
      aria-label={label}
      onKeyDown={onKeyDown}
      onPointerMove={(e) => e.pointerType === "mouse" && !reduce && mouseX.set(e.clientX)}
      onPointerLeave={() => mouseX.set(Infinity)}
      className={cn(
        "mx-auto flex w-max max-w-full items-end gap-2 rounded-2xl border border-white/20 bg-background/60 px-2.5 pb-2 pt-2 shadow-2xl shadow-black/10 backdrop-blur-xl dark:border-white/10 dark:bg-background/50 dark:shadow-black/40",
        className,
      )}
      style={{ height: base + 20 }}
    >
      {items.map((item, i) => (
        <React.Fragment key={item.id}>
          {item.separatorBefore && <span aria-hidden className="mx-1 w-px self-stretch bg-foreground/15" />}
          <DockButton
            item={item}
            mouseX={mouseX}
            size={base}
            magnification={reduce ? base : peak}
            distance={distance}
            tabIndex={i === rove ? 0 : -1}
            onFocused={() => setRove(i)}
            refCb={(el) => {
              btnRefs.current[i] = el;
            }}
          />
        </React.Fragment>
      ))}
    </div>
  );
}

interface DockButtonProps {
  item: DockItem;
  mouseX: MotionValue<number>;
  size: number;
  magnification: number;
  distance: number;
  tabIndex: number;
  onFocused: () => void;
  refCb: (el: HTMLElement | null) => void;
}

function DockButton({ item, mouseX, size, magnification, distance, tabIndex, onFocused, refCb }: DockButtonProps) {
  const ref = React.useRef<HTMLElement | null>(null);
  const [hover, setHover] = React.useState(false);
  const [focused, setFocused] = React.useState(false);
  const focusBoost = useMotionValue(0);

  const dist = useTransform(mouseX, (x) => {
    const r = ref.current?.getBoundingClientRect();
    if (!r || !Number.isFinite(x)) return distance * 2;
    return x - (r.left + r.width / 2);
  });
  const hoverSize = useTransform(dist, [-distance, 0, distance], [size, magnification, size], { clamp: true });
  const focusSize = useTransform(focusBoost, [0, 1], [size, size + (magnification - size) * 0.7]);
  const target = useTransform<number, number>([hoverSize, focusSize], ([h, f]) => Math.max(h, f));
  const width = useSpring(target, { stiffness: 320, damping: 22, mass: 0.35 });
  const iconSize = useTransform(width, (w) => w * 0.5);

  const Icon = item.icon;
  const showTip = hover || focused;
  const common = {
    ref: (el: HTMLElement | null) => {
      ref.current = el;
      refCb(el);
    },
    "aria-label": item.active ? `${item.label} (open)` : item.label,
    tabIndex,
    onPointerEnter: () => setHover(true),
    onPointerLeave: () => setHover(false),
    onFocus: () => {
      onFocused();
      setFocused(true);
      focusBoost.set(1);
    },
    onBlur: () => {
      setFocused(false);
      focusBoost.set(0);
    },
    className: cn(
      "relative grid aspect-square place-items-center rounded-[28%] shadow-md outline-none ring-offset-2 ring-offset-transparent transition-shadow focus-visible:ring-2 focus-visible:ring-ring",
      item.className ?? "bg-muted text-foreground",
    ),
  };

  const inner = (
    <>
      <motion.span style={{ width: iconSize, height: iconSize }} className="grid place-items-center">
        <Icon className="size-full" strokeWidth={1.75} />
      </motion.span>
      <AnimatePresence>
        {showTip && (
          <motion.span
            role="tooltip"
            initial={{ opacity: 0, y: 6, x: "-50%", scale: 0.9 }}
            animate={{ opacity: 1, y: 0, x: "-50%", scale: 1 }}
            exit={{ opacity: 0, y: 4, x: "-50%", scale: 0.95, transition: { duration: 0.1 } }}
            transition={{ type: "spring", stiffness: 500, damping: 30 }}
            className="pointer-events-none absolute -top-9 left-1/2 whitespace-nowrap rounded-md border bg-popover px-2 py-1 text-xs font-medium text-popover-foreground shadow-md"
          >
            {item.label}
          </motion.span>
        )}
      </AnimatePresence>
      {item.active && <span aria-hidden className="absolute -bottom-[7px] left-1/2 size-1 -translate-x-1/2 rounded-full bg-foreground/70" />}
    </>
  );

  return (
    <motion.div style={{ width }} className="relative flex shrink-0 flex-col items-center">
      {item.href ? (
        <a href={item.href} {...common} aria-current={item.active ? "page" : undefined} style={{ width: "100%" }}>
          {inner}
        </a>
      ) : (
        <button type="button" onClick={item.onClick} {...common} style={{ width: "100%" }}>
          {inner}
        </button>
      )}
    </motion.div>
  );
}

More in Navigation

View all →