mm.
← All components

Dock

A glass navigation bar with one sliding highlight and one sliding tooltip.

The idea

Every icon in a dock is competing to tell you the same two things: which one you are on, and which one you are about to pick. Most docks answer with per-item hover styles and a tooltip library, which is two systems that can disagree. This is one piece of state — so the highlight, the tooltip, the icon fill and the photo riffle are physically incapable of pointing at different icons.

Composes DropMenu and DockRiffle.

Install

terminal1 line
npm i @mihirmodi/ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Dock — usage37 lines
import { usePathname } from "next/navigation";
import { Dock, DockStrip, useDockHover, isCurrentPath } from "@mihirmodi/ui";

function Pages() {
  const path = usePathname();
  const hovered = useDockHover();

  return PAGES.map((p) => {
    const active = isCurrentPath(path, p.to);
    return (
      <Dock.Item key={p.to} label={p.label} to={p.to} active={active} riffle={p.photos}>
        {/* Fill previews a selection as well as reporting one: point at an
            icon and it fills, so the glyph answers "this one" before you
            commit. The highlight is what still separates the page you are
            ON from the one you are AT. */}
        <p.Icon filled={active || hovered === p.label} />
      </Dock.Item>
    );
  });
}

export function Nav() {
  return (
    <DockStrip>
      <Dock menu={<Settings />} collapseOnScroll>
        <Dock.Group>
          <Dock.Item label="Home" to="/"><Avatar /></Dock.Item>
          <Dock.MenuTrigger label="Settings and contact" />
        </Dock.Group>
        <Dock.Divider />
        <Dock.Group collapsible>
          <Pages />
        </Dock.Group>
      </Dock>
    </DockStrip>
  );
}

Props

PropNotes
menuReactNodeThe drop-up panel. Rendered by the dock, so it hangs from the dock as a whole rather than from the small button that opens it.
menuAlign"start" | "center" | "end"Where along the dock the panel hangs. A dock parked in a corner wants `end`, so the panel hangs inward from the edge instead of off the screen.
menuOn"hover" | "click"`hover` opens on pointer or focus and closes when the cursor leaves. `click` toggles on the trigger and closes on Escape or a press outside — a hamburger, and the only mode that works without hover.
size"md" | "sm"`md` is 40px items in a 48px pill. `sm` is 32px in 36px, a quarter smaller — for one control in a corner.
menuOpenbooleanControl the panel from outside, with `onMenuOpenChange`. Leave both out and the dock keeps its own state.
Strip.align"start" | "center" | "end"On `DockStrip`: where along its edge the dock parks. `end` puts it in the corner, with the same inset from the side it has from the top.
MenuTrigger.size"slim" | "icon"On `Dock.MenuTrigger`: `slim` is a narrow chevron leaning on the item beside it. `icon` is the 40×40 square an item gets, for a trigger standing alone.
collapseOnScrollbooleanFold the collapsible group away on scroll down; expand on scroll up, at either end of the page, or on hover. Ignored on touch, where nothing could bring it back.
labelstringAccessible name for the nav landmark.
linkLinkComponentOverride the routed link component for this dock only.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/Dock.tsx676 lines
"use client";

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type ReactNode,
} from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { DockRiffle, type RiffleFrame } from "./DockRiffle";
import { DropMenu } from "./DropMenu";
import { ChevronDownIcon, ChevronUpIcon } from "../lib/glyph";
import { useLinkComponent, type LinkComponent } from "../lib/link";

/* One spring for everything that slides in here, so the highlight and the
   tooltip can never drift apart and read as two separate objects chasing the
   cursor. Opacity is a tween for the reason given in HighlightList. */
const SPRING = {
  type: "spring",
  bounce: 0.2,
  duration: 0.4,
  opacity: { duration: 0.16, ease: "easeOut" },
} as const;

/**
 * Which edge the dock is pinned to. It is not only a position: it flips the
 * tooltip to the far side, turns the drop-up into a drop-down, and reverses the
 * disclosure chevron — a chevron has to point at what it opens.
 */
export type DockPlacement = "top" | "bottom";

/**
 * `DockStrip` publishes its placement so the `Dock` inside it inherits one —
 * otherwise the same value has to be passed twice, and the two can disagree,
 * which puts the tooltip off the top of the screen.
 */
const PlacementCtx = createContext<DockPlacement>("bottom");

/**
 * Where along its edge a strip parks the dock, and where along the dock its menu
 * hangs. `center` is the classic dock. `end` is a single control in a corner —
 * a settings gear, a hamburger — whose panel has to hang inward from the edge
 * or it runs off the screen.
 */
export type DockAlign = "start" | "center" | "end";

const JUSTIFY: Record<DockAlign, string> = {
  start: "justify-start",
  center: "justify-center",
  end: "justify-end",
};

/* The panel grows out of the corner nearest the trigger, so a menu hanging from
   the right end of a top dock scales from its top-right. */
const ORIGIN: Record<DockPlacement, Record<DockAlign, string>> = {
  top: { start: "origin-top-left", center: "origin-top", end: "origin-top-right" },
  bottom: { start: "origin-bottom-left", center: "origin-bottom", end: "origin-bottom-right" },
};

type Hover = { id: string; tip: string | null; x: number; y: number; w: number; h: number; cx: number };

export type DockSize = "md" | "sm";

/* Every box in the dock at each size, kept together so the radius ladder holds:
   the pill's radius is the item's radius plus the pill's padding, or the two
   curves visibly disagree at the corners. md: 40px items in 4px, radius 16 over
   12. sm: 32px items in 2px, radius 12 over 10 — a quarter smaller. */
const SIZES: Record<
  DockSize,
  { pill: string; item: string; auto: string; slim: string; radius: string }
> = {
  md: { pill: "rounded-2xl p-1", item: "h-10 w-10", auto: "h-10 px-3", slim: "h-10 w-6", radius: "rounded-[12px]" },
  sm: { pill: "rounded-xl p-0.5", item: "h-8 w-8", auto: "h-8 px-2.5", slim: "h-8 w-5", radius: "rounded-[10px]" },
};

type DockCtx = {
  hover: Hover | null;
  enter: (el: HTMLElement, id: string, tip: string | null) => void;
  clear: () => void;
  /** True while the cursor is anywhere on the dock. Arms lazy work like the riffle. */
  dockHover: boolean;
  /** False only when the dock has collapsed on scroll and is not being hovered. */
  expanded: boolean;
  menuOpen: boolean;
  menuOn: "hover" | "click";
  openMenu: () => void;
  closeMenuSoon: () => void;
  closeMenuNow: () => void;
  placement: DockPlacement;
  size: DockSize;
  link?: LinkComponent;
};

const Ctx = createContext<DockCtx | null>(null);

function useDock(): DockCtx {
  const ctx = useContext(Ctx);
  if (!ctx) throw new Error("Dock.Item, Dock.Group, Dock.Divider and Dock.MenuTrigger must be used inside <Dock>.");
  return ctx;
}

/* ── Strip ──────────────────────────────────────────────────────────────── */

/**
 * The fixed, bottom-centred lane a dock lives in.
 *
 * Separate from `Dock` so a dock can also be rendered inline — in a demo, in a
 * settings panel, in a story. It also lets a second piece of chrome sit beside
 * the dock: the row centres as a whole, so the extra element shifts the dock
 * left rather than overlapping it.
 *
 * It is click-through, so the docks inside it are the only hit targets. And it
 * deliberately carries **no transform**: a transform here would become the
 * containing block for any `position: fixed` descendant, which quietly breaks a
 * full-screen overlay opened from inside the dock.
 */
export function DockStrip({
  placement = "bottom",
  align = "center",
  className = "",
  children,
}: {
  placement?: DockPlacement;
  /** Where along the edge the dock sits. Default `center`. */
  align?: DockAlign;
  className?: string;
  children: ReactNode;
}) {
  return (
    <PlacementCtx.Provider value={placement}>
      <div
        // A dock parked at an end gets the same inset from the side that it has
        // from the top or bottom, so it sits in the corner rather than on the edge.
        className={`pointer-events-none fixed inset-x-0 z-50 flex items-center gap-2 ${JUSTIFY[align]} ${
          align === "center" ? "" : "px-4"
        } ${placement === "top" ? "top-4" : "bottom-5"} ${className}`}
      >
        {children}
      </div>
    </PlacementCtx.Provider>
  );
}

/* ── Dock ───────────────────────────────────────────────────────────────── */

export type DockProps = {
  children: ReactNode;
  /**
   * The drop-up panel opened by `Dock.MenuTrigger`. Rendered by the dock rather
   * than by the trigger, so it can centre on the dock as a whole instead of on
   * the small button that opens it.
   */
  menu?: ReactNode;
  /**
   * Where along the dock the menu hangs. Default `center`. A dock parked in a
   * corner wants `start` or `end`, so the panel hangs inward from the edge.
   */
  menuAlign?: DockAlign;
  /**
   * What opens the menu. `hover` (default) opens on pointer or focus and closes
   * when the cursor leaves the dock — the drop-up over a bottom dock. `click`
   * toggles on the trigger and closes on Escape or a press outside, which is how
   * a hamburger or a settings button in a corner is expected to behave, and the
   * only way it can work at all where there is no hover.
   */
  menuOn?: "hover" | "click";
  /**
   * Control the menu from outside. Leave both out and the dock keeps its own
   * state; pass `menuOpen` with `onMenuOpenChange` to close it when the route
   * changes, or to open it from somewhere else.
   */
  menuOpen?: boolean;
  onMenuOpenChange?: (open: boolean) => void;
  /**
   * Collapse to the first group as you scroll down, expand on scroll up, at the
   * top, at the bottom, or on hover. Off by default — a dock rendered inline has
   * nothing to collapse for.
   */
  collapseOnScroll?: boolean;
  /** Accessible name for the nav landmark. Default "Primary". */
  label?: string;
  /**
   * Which edge this is pinned to. Inherited from `DockStrip` when it is inside
   * one; pass it here for a dock rendered inline.
   */
  placement?: DockPlacement;
  /**
   * `md` is 40px items in a 48px pill. `sm` is 32px items in a 36px pill — for
   * a single control in a corner, where the full pill is more chrome than the
   * one button inside it deserves.
   */
  size?: DockSize;
  className?: string;
  /** Override the routed link component for this dock only. */
  link?: LinkComponent;
};

/**
 * A glass dock: a row of icon buttons with a single highlight and a single name
 * tooltip that slide to whichever one you are pointing at.
 *
 * ── Why the highlight and the tooltip are here and not on the items ──────────
 *
 * Both are one element owned by the dock, re-targeting its position, for the
 * reason `HighlightList` gives at length: per-item versions flicker across the
 * gaps and say nothing about where the cursor came from. Here it matters twice
 * over, because the tooltip and the highlight run off the *same* state — so they
 * can never disagree about which icon is being pointed at, which is exactly the
 * bug you get when a tooltip library and a hover style are wired up separately.
 *
 * That same state is also set on focus, so the keyboard gets the highlight, the
 * tooltip, the icon fill and the riffle without any of them being asked twice.
 *
 * ── Composition ─────────────────────────────────────────────────────────────
 *
 *     <DockStrip>
 *       <Dock menu={<Settings />} collapseOnScroll>
 *         <Dock.Group>
 *           <Dock.Item label="Home" to="/">{avatar}</Dock.Item>
 *           <Dock.MenuTrigger label="Settings" />
 *         </Dock.Group>
 *         <Dock.Divider />
 *         <Dock.Group collapsible>
 *           <Dock.Item label="Work" to="/work" active={path === "/work"}>
 *             <WorkIcon filled={path === "/work"} />
 *           </Dock.Item>
 *         </Dock.Group>
 *       </Dock>
 *     </DockStrip>
 */
function DockRoot({
  children,
  menu,
  menuAlign = "center",
  menuOn = "hover",
  menuOpen: menuOpenProp,
  onMenuOpenChange,
  collapseOnScroll = false,
  label = "Primary",
  placement,
  size = "md",
  className = "",
  link,
}: DockProps) {
  const inherited = useContext(PlacementCtx);
  const edge = placement ?? inherited;
  const [hover, setHover] = useState<Hover | null>(null);
  const [dockHover, setDockHover] = useState(false);
  const [menuOpenState, setMenuOpenState] = useState(false);
  const [shrunk, setShrunk] = useState(false);
  const reduce = useReducedMotion();
  const closeTimer = useRef<number | null>(null);
  const navRef = useRef<HTMLElement>(null);

  // Controlled when the parent passes `menuOpen`; the local copy is still
  // written so an uncontrolled dock and a controlled one share every code path.
  const menuOpen = menuOpenProp ?? menuOpenState;
  const setMenuOpen = useCallback(
    (open: boolean) => {
      setMenuOpenState(open);
      onMenuOpenChange?.(open);
    },
    [onMenuOpenChange]
  );

  const expanded = !shrunk || dockHover;
  const tween = reduce ? { duration: 0.15 } : SPRING;

  const openMenu = useCallback(() => {
    if (closeTimer.current) {
      clearTimeout(closeTimer.current);
      closeTimer.current = null;
    }
    setMenuOpen(true);
  }, [setMenuOpen]);

  // A grace period, so moving the cursor off the trigger and into the panel does
  // not close the thing you are travelling towards. 100ms is enough to cross a
  // gap and short enough that an actual departure still feels immediate.
  const closeMenuSoon = useCallback(() => {
    if (closeTimer.current) clearTimeout(closeTimer.current);
    closeTimer.current = window.setTimeout(() => setMenuOpen(false), 100);
  }, [setMenuOpen]);

  const closeMenuNow = useCallback(() => {
    if (closeTimer.current) clearTimeout(closeTimer.current);
    setMenuOpen(false);
  }, [setMenuOpen]);

  useEffect(() => () => void (closeTimer.current && clearTimeout(closeTimer.current)), []);

  // A click-opened menu has no cursor leaving to close it, so it closes the two
  // ways every dropdown is expected to: Escape, and a press anywhere outside.
  // `pointerdown` rather than `click`, so it is gone before whatever was pressed
  // instead gets to respond.
  useEffect(() => {
    if (menuOn !== "click" || !menuOpen) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") closeMenuNow();
    };
    const onDown = (e: PointerEvent) => {
      if (navRef.current && !navRef.current.contains(e.target as Node)) closeMenuNow();
    };
    window.addEventListener("keydown", onKey);
    window.addEventListener("pointerdown", onDown);
    return () => {
      window.removeEventListener("keydown", onKey);
      window.removeEventListener("pointerdown", onDown);
    };
  }, [menuOn, menuOpen, closeMenuNow]);

  useEffect(() => {
    if (!collapseOnScroll) return;
    // Only where a cursor can bring it back. On a touch screen there is no hover,
    // so a collapsed dock would strand everything inside it for good.
    if (!window.matchMedia("(hover: hover) and (pointer: fine)").matches) return;

    let last = window.scrollY;
    let ticking = false;
    const update = () => {
      const y = window.scrollY;
      const atBottom = y + window.innerHeight >= document.documentElement.scrollHeight - 2;
      if (y < 24 || atBottom) setShrunk(false);
      // A 6px deadband. Without it, the sub-pixel jitter of a trackpad flings the
      // dock open and shut while you are holding still.
      else if (y > last + 6) setShrunk(true);
      else if (y < last - 6) setShrunk(false);
      last = y;
      ticking = false;
    };
    const onScroll = () => {
      if (!ticking) {
        ticking = true;
        requestAnimationFrame(update);
      }
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, [collapseOnScroll]);

  const enter = useCallback((el: HTMLElement, id: string, tip: string | null) => {
    setHover({
      id,
      tip,
      x: el.offsetLeft,
      y: el.offsetTop,
      w: el.offsetWidth,
      h: el.offsetHeight,
      cx: el.offsetLeft + el.offsetWidth / 2,
    });
  }, []);

  const clear = useCallback(() => setHover(null), []);

  const ctx = useMemo<DockCtx>(
    () => ({
      hover,
      enter,
      clear,
      dockHover,
      expanded,
      menuOpen,
      menuOn,
      openMenu,
      closeMenuSoon,
      closeMenuNow,
      placement: edge,
      size,
      link,
    }),
    [hover, enter, clear, dockHover, expanded, menuOpen, menuOn, closeMenuSoon, openMenu, closeMenuNow, edge, size, link]
  );

  return (
    <nav ref={navRef} aria-label={label} className="pointer-events-auto flex">
      <LazyMotion features={domAnimation} strict>
        <Ctx.Provider value={ctx}>
          <ul
            // `relative` makes this the offset parent every item measures against,
            // and `isolate` gives the -z-10 highlight a stacking context to sit in
            // — without it the highlight escapes behind the page.
            className={`glass-nav dock-shadow relative isolate flex items-center border-[0.5px] border-gray-alpha-400 ${SIZES[size].pill} ${className}`}
            onMouseEnter={() => setDockHover(true)}
            onMouseLeave={() => {
              setDockHover(false);
              setHover(null);
              // A clicked-open menu stays put when the cursor wanders; it
              // closes on Escape or a press outside instead.
              if (menuOn === "hover") closeMenuNow();
            }}
          >
            {menu && (
              <DropMenu
                open={menuOpen}
                placement={edge === "top" ? "down" : "up"}
                origin={ORIGIN[edge][menuAlign]}
                onMouseEnter={menuOn === "hover" ? openMenu : undefined}
                onMouseLeave={menuOn === "hover" ? closeMenuSoon : undefined}
                className={`absolute inset-x-0 flex ${JUSTIFY[menuAlign]} ${
                  edge === "top" ? "top-full pt-2" : "bottom-full pb-2"
                }`}
              >
                {menu}
              </DropMenu>
            )}

            {/* The name of whatever is being pointed at. `x` is the item's centre
                and the inner div pulls itself back by half its own width, so one
                element can centre over items of any width without measuring them. */}
            <m.div
              aria-hidden
              className={`pointer-events-none absolute left-0 ${
                edge === "top" ? "top-full mt-2" : "bottom-full mb-2"
              }`}
              initial={false}
              animate={hover?.tip ? { x: hover.cx, opacity: 1 } : { opacity: 0 }}
              transition={tween}
            >
              <div className="-translate-x-1/2 whitespace-nowrap rounded-md bg-gray-1000 px-2 py-1 text-[11px] font-normal leading-none text-background-100 shadow-[0_4px_10px_-4px_rgba(0,0,0,0.3)]">
                {hover?.tip}
              </div>
            </m.div>

            {/* The highlight, behind the icons. */}
            <m.div
              aria-hidden
              className={`pointer-events-none absolute left-0 top-0 -z-10 bg-highlight ${SIZES[size].radius}`}
              initial={false}
              animate={
                hover
                  ? { x: hover.x, y: hover.y, width: hover.w, height: hover.h, opacity: 1 }
                  : { opacity: 0 }
              }
              transition={tween}
            />

            {children}
          </ul>
        </Ctx.Provider>
      </LazyMotion>
    </nav>
  );
}

/* ── Group ──────────────────────────────────────────────────────────────── */

/**
 * A run of items. Mark one `collapsible` and it folds away when the dock
 * collapses on scroll, leaving the groups before it.
 *
 * The fold is a CSS grid animating `grid-template-columns` between `1fr` and
 * `0fr`, with the child `overflow-hidden` and `min-w-0`. That is the one way to
 * animate to and from *content width* without measuring anything in JavaScript:
 * width has no animatable "auto", but a grid track does.
 */
function DockGroup({
  children,
  collapsible = false,
  className = "",
}: {
  children: ReactNode;
  collapsible?: boolean;
  className?: string;
}) {
  const { expanded, clear } = useDock();

  if (!collapsible) {
    return <li className={`flex items-center ${className}`}>{children}</li>;
  }

  return (
    <li
      className="grid transition-[grid-template-columns] duration-300 ease-geist motion-reduce:transition-none"
      style={{ gridTemplateColumns: expanded ? "1fr" : "0fr" }}
    >
      <div className={`flex min-w-0 items-center overflow-hidden ${className}`} onMouseLeave={clear}>
        {children}
      </div>
    </li>
  );
}

/** A hairline between groups. Translucent, because it sits on glass. */
function DockDivider() {
  return <span aria-hidden className="mx-1 h-6 w-[0.5px] shrink-0 bg-gray-alpha-300" />;
}

/* ── Item ───────────────────────────────────────────────────────────────── */

export type DockItemProps = {
  /** The tooltip text and the accessible name. Required — an icon alone is not a name. */
  label: string;
  /** A routed destination, sent through your link component. */
  to?: string;
  /** An external or non-routed destination. Opens in a new tab. */
  href?: string;
  /** Renders a `<button>` instead of a link. */
  onClick?: () => void;
  /**
   * Whether this is the chosen one. Drives the resting pill, and reports itself
   * as `aria-current="page"` on a link or `aria-pressed` on a button — a button
   * is not a destination, so it cannot be the current page.
   */
  active?: boolean;
  /**
   * `icon` is a 40×40 square; `auto` grows to its content, for a wordmark or a
   * text link.
   */
  size?: "icon" | "auto";
  /**
   * What the sliding tooltip says. Defaults to `label`. Pass `false` on a text
   * item — it already says what it is, and a tooltip repeating it is noise.
   */
  tooltip?: string | false;
  /** Images that riffle through this button while it is pointed at. */
  riffle?: readonly RiffleFrame[];
  className?: string;
  children: ReactNode;
};

/**
 * One button in the dock.
 *
 * Pass the icon as children and decide its own `filled` state yourself, because
 * fill here previews a selection as well as reporting one: point at an icon and
 * it fills, so the glyph answers "this one" before you commit. That does mean two
 * icons can be solid at once — the highlight is what still separates the page you
 * are *on* from the one you are *at*. Read `hovered` off `useDockHover()` if you
 * want that behaviour:
 *
 *     <Dock.Item label="Work" to="/work" active={here}>
 *       <WorkIcon filled={here || hovered === "Work"} />
 *     </Dock.Item>
 *
 * The resting pill on the active item is suppressed while the cursor is anywhere
 * on the dock. Two filled chips at once — one parked, one following the cursor —
 * read as a bug; while you are pointing, the sliding one is the only one that
 * should be speaking.
 */
function DockItem({
  label,
  to,
  href,
  onClick,
  active = false,
  size = "icon",
  tooltip,
  riffle,
  className = "",
  children,
}: DockItemProps) {
  const { enter, hover, dockHover, size: dockSize, link } = useDock();
  const dims = SIZES[dockSize];
  const Link = useLinkComponent(link);
  const id = useId();
  const playing = hover?.id === id;
  const tip = tooltip === false ? null : (tooltip ?? label);

  const base = `relative flex shrink-0 items-center justify-center ${dims.radius} transition-[color,background-color,box-shadow,transform] duration-150 ease-geist active:scale-[0.92] ${
    size === "auto" ? `w-auto gap-2 ${dims.auto}` : dims.item
  }`;
  const tone = active
    ? `text-gray-1000 ${dockHover ? "" : "bg-highlight shadow-[0_2px_2px_rgba(0,0,0,0.04)]"}`
    : "text-quiet hover:text-gray-1000";

  const body = riffle ? (
    <DockRiffle icon={children} frames={riffle} play={playing} armed={dockHover} />
  ) : (
    children
  );

  const handlers = {
    onMouseEnter: (e: { currentTarget: HTMLElement }) => enter(e.currentTarget, id, tip),
    onFocus: (e: { currentTarget: HTMLElement }) => enter(e.currentTarget, id, tip),
  };

  const props = {
    "aria-label": label,
    className: `${base} ${tone} ${className}`,
    ...handlers,
  };

  if (onClick) {
    // A button is not a destination, so `active` here means "this option is
    // chosen", not "this is the page you are on". `aria-current="page"` on a
    // theme toggle tells a screen reader something plainly untrue.
    return (
      <button type="button" onClick={onClick} aria-pressed={active} {...props}>
        {body}
      </button>
    );
  }
  if (href) {
    return (
      <a href={href} target="_blank" rel="noopener" {...props}>
        {body}
      </a>
    );
  }
  return (
    <Link href={to ?? "#"} aria-current={active ? "page" : undefined} {...props}>
      {body}
    </Link>
  );
}

/* ── Menu trigger ───────────────────────────────────────────────────────── */

/**
 * The permanent chevron that opens the dock's `menu` panel.
 *
 * Under the default `menuOn="hover"` it opens on hover and on focus; under
 * `click` it toggles. Either way it carries no hover background of its own — it
 * is a disclosure for the panel above, not a destination, and giving it the same
 * pill the pages get would file it with them.
 *
 * `slim` is the default: a narrow chevron leaning on the item beside it. `icon`
 * is the same 40×40 square an item gets, for a trigger that stands alone in its
 * dock — a hamburger in a corner — where a 24px-wide button would leave the pill
 * taller than it is wide.
 */
function DockMenuTrigger({
  label = "Settings",
  size = "slim",
  children,
}: {
  label?: string;
  size?: "slim" | "icon";
  children?: ReactNode;
}) {
  const { menuOpen, menuOn, openMenu, closeMenuSoon, closeMenuNow, placement, size: dockSize } = useDock();
  const dims = SIZES[dockSize];
  // A disclosure points at what it opens, so the chevron follows the edge.
  const Chevron = placement === "top" ? ChevronDownIcon : ChevronUpIcon;
  const handlers =
    menuOn === "hover"
      ? { onMouseEnter: openMenu, onMouseLeave: closeMenuSoon, onFocus: openMenu, onBlur: closeMenuSoon }
      : { onClick: () => (menuOpen ? closeMenuNow() : openMenu()) };
  return (
    <button
      type="button"
      aria-haspopup="true"
      aria-expanded={menuOpen}
      aria-label={label}
      {...handlers}
      className={`flex items-center justify-center transition-colors duration-150 ease-geist ${
        size === "icon" ? `${dims.item} ${dims.radius} active:scale-[0.92]` : `ml-0.5 ${dims.slim}`
      } ${menuOpen ? "text-gray-1000" : "text-quiet hover:text-gray-1000"}`}
    >
      {children ?? <Chevron />}
    </button>
  );
}

/**
 * The label of the item currently being pointed at (or focused), or null.
 *
 * For an icon that wants to preview its own fill on hover. It reads the state the
 * highlight and the tooltip already run off, so it cannot disagree with them.
 */
export function useDockHover(): string | null {
  return useDock().hover?.tip ?? null;
}

export const Dock = Object.assign(DockRoot, {
  Group: DockGroup,
  Divider: DockDivider,
  Item: DockItem,
  MenuTrigger: DockMenuTrigger,
  Strip: DockStrip,
});
packages/ui/src/lib/link.tsx49 lines
"use client";

import { createContext, useContext, type AnchorHTMLAttributes, type ComponentType, type ReactNode } from "react";

/**
 * Anything that renders an `<a>` given an `href`. Next's `Link`, React Router's
 * `Link` (rename `to`), TanStack's, or nothing at all.
 */
export type LinkComponent = ComponentType<
  { href: string; children?: ReactNode } & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href">
>;

const LinkContext = createContext<LinkComponent | null>(null);

/**
 * Teaches every component beneath it how to route.
 *
 * Without it the components render plain `<a>` tags, which navigate correctly
 * but reload the page — fine for a marketing site, wrong inside an app. Rather
 * than depend on a router (which would tie this package to one framework), the
 * dependency is inverted: you hand in the link component you already use.
 *
 *     import Link from "next/link";
 *     <UIProvider link={Link}>{children}</UIProvider>
 *
 * Every component also takes a `link` prop, so a single odd one out does not
 * need its own provider.
 */
export function UIProvider({ link, children }: { link?: LinkComponent; children: ReactNode }) {
  return <LinkContext.Provider value={link ?? null}>{children}</LinkContext.Provider>;
}

/** The nearest provided link component, or `"a"` when there is none. */
export function useLinkComponent(override?: LinkComponent): LinkComponent | "a" {
  const ctx = useContext(LinkContext);
  return override ?? ctx ?? "a";
}

/**
 * Whether a destination is the current one — React Router's NavLink semantics,
 * kept: "/" matches exactly, everything else matches itself and its descendants,
 * so a nav entry stays lit while you are reading one of its children.
 *
 * Exported because the dock takes `active` as a boolean rather than computing it:
 * the dock has no idea what your routes mean, but you do.
 */
export function isCurrentPath(pathname: string, to: string): boolean {
  return to === "/" ? pathname === "/" : pathname === to || pathname.startsWith(`${to}/`);
}