Componentnavigation3.25 KB gzipZero dependencies

Dock

Floating application dock bar with hover magnification and active indicators.

Preview

Live Preview
HOVER TO MAGNIFY · CLICK TO SWITCH ACTIVE STUB APP
CURRENT WORKSPACE: terminal

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add dock

Or install the full npm package:

npm install bigbullui
import { Dock, DockItem } from "@/components/ui/dock";

<Dock>
  <DockItem label="Home" onClick={() => {}} />
  <DockItem label="Search" onClick={() => {}} />
</Dock>

Usage guidance

Use Dock when a ticket stub surface needs Floating application dock bar with hover magnification and active indicators. Keep props minimal and prefer composition with sibling stubs.

Avoid Dock for long form flows or dense data grids where a dedicated layout block fits better.

Source code

dock.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface DockContextValue {
  mouseX: number | null;
  mouseY: number | null;
  orientation: "horizontal" | "vertical";
  direction: "bottom" | "top" | "left" | "right";
  magnification: number;
  distance: number;
  iconSize: number;
  magnify: boolean;
}

const DockContext = React.createContext<DockContextValue>({
  mouseX: null,
  mouseY: null,
  orientation: "horizontal",
  direction: "bottom",
  magnification: 1.45,
  distance: 130,
  iconSize: 44,
  magnify: true,
});

export function useDock() {
  return React.useContext(DockContext);
}

export interface DockItemData {
  id?: string;
  label: string;
  icon: React.ReactNode;
  active?: boolean;
  badge?: string | number | boolean;
  shortcut?: string;
  onClick?: () => void;
  href?: string;
  disabled?: boolean;
}

export interface DockProps extends React.HTMLAttributes<HTMLDivElement> {
  children?: React.ReactNode;
  items?: DockItemData[];
  orientation?: "horizontal" | "vertical";
  direction?: "bottom" | "top" | "left" | "right";
  magnification?: number;
  distance?: number;
  iconSize?: number;
  magnify?: boolean;
  showNotches?: boolean;
  serialNumber?: string;
  className?: string;
}

export function Dock({
  children,
  items,
  orientation = "horizontal",
  direction = "bottom",
  magnification = 1.45,
  distance = 130,
  iconSize = 44,
  magnify = true,
  showNotches = true,
  serialNumber = "STUB // DOCK",
  className,
  ...props
}: DockProps) {
  const [mousePos, setMousePos] = React.useState<{ x: number | null; y: number | null }>({
    x: null,
    y: null,
  });

  const dockRef = React.useRef<HTMLDivElement>(null);

  const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
    if (!dockRef.current) return;
    const rect = dockRef.current.getBoundingClientRect();
    setMousePos({
      x: e.clientX - rect.left,
      y: e.clientY - rect.top,
    });
  };

  const handleMouseLeave = () => {
    setMousePos({ x: null, y: null });
  };

  const contextValue = React.useMemo<DockContextValue>(
    () => ({
      mouseX: mousePos.x,
      mouseY: mousePos.y,
      orientation,
      direction,
      magnification,
      distance,
      iconSize,
      magnify,
    }),
    [mousePos, orientation, direction, magnification, distance, iconSize, magnify]
  );

  return (
    <DockContext.Provider value={contextValue}>
      <div
        ref={dockRef}
        onMouseMove={handleMouseMove}
        onMouseLeave={handleMouseLeave}
        className={cn(
          "relative inline-flex items-center gap-2 rounded-xl border-2 border-foreground bg-card/90 p-2 shadow-2xl backdrop-blur-md select-none transition-all duration-200",
          orientation === "horizontal"
            ? "flex-row h-16 px-4"
            : "flex-col w-16 py-4",
          className
        )}
        role="toolbar"
        aria-label="Application Dock"
        {...props}
      >
        {/* Ticket Notch Cutouts on Dock Ends */}
        {showNotches && orientation === "horizontal" && (
          <>
            <div
              className="pointer-events-none absolute -left-2.5 top-1/2 -translate-y-1/2 size-4 rounded-full bg-background border-2 border-foreground shadow-inner z-20"
              aria-hidden="true"
            />
            <div
              className="pointer-events-none absolute -right-2.5 top-1/2 -translate-y-1/2 size-4 rounded-full bg-background border-2 border-foreground shadow-inner z-20"
              aria-hidden="true"
            />
          </>
        )}

        {showNotches && orientation === "vertical" && (
          <>
            <div
              className="pointer-events-none absolute left-1/2 -top-2.5 -translate-x-1/2 size-4 rounded-full bg-background border-2 border-foreground shadow-inner z-20"
              aria-hidden="true"
            />
            <div
              className="pointer-events-none absolute left-1/2 -bottom-2.5 -translate-x-1/2 size-4 rounded-full bg-background border-2 border-foreground shadow-inner z-20"
              aria-hidden="true"
            />
          </>
        )}

        {/* Micro Stub Tag Eyebrow */}
        {serialNumber && (
          <div
            className={cn(
              "pointer-events-none absolute font-mono text-[8px] font-bold uppercase tracking-widest text-muted-foreground/60 select-none",
              orientation === "horizontal"
                ? "-top-4 left-4"
                : "-left-5 top-4 -rotate-90 origin-left"
            )}
            aria-hidden="true"
          >
            {serialNumber}
          </div>
        )}

        {/* Render Items either via children or items prop */}
        {children
          ? children
          : items?.map((item, idx) => (
              <DockItem
                key={item.id || idx}
                icon={item.icon}
                label={item.label}
                active={item.active}
                badge={item.badge}
                shortcut={item.shortcut}
                onClick={item.onClick}
                href={item.href}
                disabled={item.disabled}
              />
            ))}
      </div>
    </DockContext.Provider>
  );
}

export interface DockItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  icon?: React.ReactNode;
  label?: string;
  active?: boolean;
  badge?: string | number | boolean;
  shortcut?: string;
  href?: string;
  className?: string;
  children?: React.ReactNode;
}

export function DockItem({
  icon,
  label,
  active = false,
  badge,
  shortcut,
  href,
  className,
  children,
  onClick,
  disabled,
  ...props
}: DockItemProps) {
  const { mouseX, mouseY, orientation, direction, magnification, distance, iconSize, magnify } = useDock();
  const itemRef = React.useRef<HTMLButtonElement>(null);
  const [isHovered, setIsHovered] = React.useState(false);
  const [isPressed, setIsPressed] = React.useState(false);

  // Calculate dynamic magnification based on distance to cursor
  const scale = React.useMemo(() => {
    if (!magnify || !itemRef.current) return 1;

    if (orientation === "horizontal") {
      if (mouseX === null) return 1;
      const el = itemRef.current;
      const itemCenter = el.offsetLeft + el.offsetWidth / 2;
      const delta = Math.abs(mouseX - itemCenter);
      if (delta > distance) return 1;
      const factor = Math.cos((delta / distance) * (Math.PI / 2));
      return 1 + (magnification - 1) * factor;
    } else {
      if (mouseY === null) return 1;
      const el = itemRef.current;
      const itemCenter = el.offsetTop + el.offsetHeight / 2;
      const delta = Math.abs(mouseY - itemCenter);
      if (delta > distance) return 1;
      const factor = Math.cos((delta / distance) * (Math.PI / 2));
      return 1 + (magnification - 1) * factor;
    }
  }, [magnify, orientation, mouseX, mouseY, distance, magnification]);

  const computedSize = iconSize * scale;

  const content = (
    <div
      className={cn(
        "relative flex items-center justify-center rounded-lg border-2 transition-all duration-100 ease-out",
        active
          ? "border-foreground bg-primary text-primary-foreground shadow-md"
          : "border-border bg-secondary/80 text-foreground hover:border-foreground/80 hover:bg-secondary shadow-xs",
        isPressed && "scale-90"
      )}
      style={{
        width: `${computedSize}px`,
        height: `${computedSize}px`,
      }}
    >
      {/* Icon rendering */}
      <div className="flex items-center justify-center pointer-events-none transition-transform duration-100" style={{ transform: `scale(${Math.min(scale, 1.25)})` }}>
        {icon || children}
      </div>

      {/* Notification Badge */}
      {badge !== undefined && badge !== false && (
        <span
          className="absolute -top-1 -right-1 flex h-4 min-w-4 items-center justify-center rounded-xs bg-accent px-1 font-mono text-[9px] font-bold text-accent-foreground border border-card shadow-xs z-30"
          aria-label={typeof badge === "string" || typeof badge === "number" ? `Badge: ${badge}` : "Notification"}
        >
          {badge === true ? "" : badge}
        </span>
      )}
    </div>
  );

  return (
    <div
      className={cn(
        "relative flex flex-col items-center justify-center group focus-within:z-40",
        orientation === "vertical" && "flex-row"
      )}
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => {
        setIsHovered(false);
        setIsPressed(false);
      }}
    >
      {/* Tooltip Label */}
      {label && isHovered && (
        <div
          className={cn(
            "pointer-events-none absolute z-50 flex items-center gap-1.5 whitespace-nowrap rounded-xs border-2 border-dashed border-border bg-card/95 px-2 py-0.5 text-foreground shadow-xl backdrop-blur-xs transition-opacity duration-150 animate-fade-in-up",
            direction === "bottom" && "-top-9",
            direction === "top" && "-bottom-9",
            direction === "left" && "-right-24",
            direction === "right" && "-left-24"
          )}
          role="tooltip"
        >
          <span className="font-mono text-[10px] font-bold uppercase tracking-wider">
            {label}
          </span>
          {shortcut && (
            <kbd className="rounded-2xs border border-border/80 bg-secondary px-1 font-mono text-[8px] font-bold text-muted-foreground">
              {shortcut}
            </kbd>
          )}

          {/* Ticket Beak Pointer */}
          {direction === "bottom" && (
            <div className="absolute -bottom-1 left-1/2 -translate-x-1/2 size-1.5 rotate-45 border-b-2 border-r-2 border-dashed border-border bg-card" />
          )}
          {direction === "top" && (
            <div className="absolute -top-1 left-1/2 -translate-x-1/2 size-1.5 rotate-45 border-t-2 border-l-2 border-dashed border-border bg-card" />
          )}
        </div>
      )}

      {/* Interactive Button */}
      <button
        ref={itemRef}
        type="button"
        disabled={disabled}
        onClick={(e) => {
          setIsPressed(true);
          setTimeout(() => setIsPressed(false), 200);
          onClick?.(e);
          if (href) {
            window.location.href = href;
          }
        }}
        className={cn(
          "relative flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-lg cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
          className
        )}
        aria-label={label || "Dock Item"}
        {...props}
      >
        {content}
      </button>

      {/* Active Indicator Dot */}
      <div
        className={cn(
          "flex items-center justify-center transition-all duration-200",
          orientation === "horizontal" ? "h-2 w-full pt-1" : "w-2 h-full ps-1"
        )}
      >
        {active ? (
          <span
            className="size-1.5 rounded-full bg-accent shadow-[0_0_6px_var(--color-accent)] animate-pulse"
            aria-hidden="true"
          />
        ) : (
          <span className="size-1.5 rounded-full bg-transparent" aria-hidden="true" />
        )}
      </div>
    </div>
  );
}

export interface DockSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
  className?: string;
}

export function DockSeparator({ className, ...props }: DockSeparatorProps) {
  const { orientation } = useDock();

  return (
    <div
      className={cn(
        "shrink-0 select-none",
        orientation === "horizontal"
          ? "h-8 w-0 border-r-2 border-dashed border-border/80 mx-1.5 self-center"
          : "w-8 h-0 border-b-2 border-dashed border-border/80 my-1.5 self-center",
        className
      )}
      role="separator"
      aria-orientation={orientation === "horizontal" ? "vertical" : "horizontal"}
      {...props}
    />
  );
}

export interface DockLabelProps extends React.HTMLAttributes<HTMLDivElement> {
  children: React.ReactNode;
  shortcut?: string;
  className?: string;
}

export function DockLabel({ children, shortcut, className, ...props }: DockLabelProps) {
  return (
    <div
      className={cn(
        "inline-flex items-center gap-1.5 rounded-xs border-2 border-dashed border-border bg-card/95 px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-wider text-foreground shadow-md backdrop-blur-xs",
        className
      )}
      {...props}
    >
      <span>{children}</span>
      {shortcut && (
        <kbd className="rounded-2xs border border-border/80 bg-secondary px-1 font-mono text-[8px] font-bold text-muted-foreground">
          {shortcut}
        </kbd>
      )}
    </div>
  );
}

Props

PropTypeDescription
children?React.ReactNode
items?DockItemData[]
orientation?"horizontal" | "vertical"
direction?"bottom" | "top" | "left" | "right"
magnification?number
distance?number
iconSize?number
magnify?boolean
showNotches?boolean
serialNumber?string
className?string