{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dock",
  "title": "Dock",
  "description": "A row of icon buttons that swell near the pointer and rise without moving the page: a glass bar, bare tiles, a side rail or words under every icon.",
  "dependencies": [
    "motion",
    "radix-ui"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/tooltip.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/dock.tsx",
      "content": "import type * as React from \"react\"\r\nimport { createContext, useContext, useMemo, useRef } from \"react\"\r\nimport { Toolbar as ToolbarPrimitive } from \"radix-ui\"\r\nimport { useReducedMotion } from \"motion/react\"\r\nimport { hopOnce, useDockMagnify } from \"@/registry/miseui/ui/dock-magnify\"\r\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"@/registry/miseui/ui/tooltip\"\r\nimport { cn } from \"@/registry/miseui/lib/utils\"\r\n\r\ntype DockVariant = \"glass\" | \"plain\" | \"vertical\" | \"labeled\"\r\ntype DockEffect = \"magnify\" | \"bounce\"\r\n\r\n/** Concentric with the 8 px of padding around a tile. */\r\nconst BAR_RADIUS = \"rounded-[calc(var(--radius)+4px)]\"\r\n\r\nconst SURFACE_CLASS: Record<DockVariant, string> = {\r\n  glass: cn(\r\n    BAR_RADIUS,\r\n    \"bg-background/70 shadow-lg ring-1 shadow-black/5 ring-foreground/10 backdrop-blur-xl backdrop-saturate-150 dark:bg-card/70 dark:shadow-black/40\",\r\n    \"[@media(prefers-reduced-transparency:reduce)]:bg-background [@media(prefers-reduced-transparency:reduce)]:backdrop-blur-none\",\r\n  ),\r\n  plain: \"hidden\",\r\n  vertical: cn(BAR_RADIUS, \"bg-card ring-1 ring-border\"),\r\n  labeled: cn(BAR_RADIUS, \"bg-card ring-1 ring-border\"),\r\n}\r\n\r\nconst LIST_CLASS: Record<DockVariant, string> = {\r\n  glass: \"flex gap-1 p-2\",\r\n  plain: \"flex gap-2 p-1\",\r\n  vertical: \"flex flex-col gap-1 p-2\",\r\n  labeled: \"flex gap-1 p-2\",\r\n}\r\n\r\nconst TILE_BASE_CLASS = [\r\n  \"relative inline-flex items-center justify-center rounded-md outline-none select-none\",\r\n  \"transition-[color,background-color,scale] duration-(--duration-fast) ease-(--ease-mise)\",\r\n  \"focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\",\r\n  \"active:scale-95 active:duration-100 motion-reduce:active:scale-100\",\r\n  \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5\",\r\n]\r\n\r\n/** The icon-only rows stay at 40 px on every input, so a finger needs no special case. */\r\nconst TILE_CLASS: Record<DockVariant, { shape: string; idle: string; active: string }> = {\r\n  glass: {\r\n    shape: \"size-10 origin-bottom\",\r\n    idle: \"text-muted-foreground hover:bg-foreground/5 hover:text-foreground\",\r\n    active: \"text-foreground\",\r\n  },\r\n  plain: {\r\n    shape: \"size-10 origin-bottom shadow-xs ring-1\",\r\n    idle: \"bg-card text-muted-foreground ring-border hover:text-foreground\",\r\n    active: \"bg-mise text-mise-foreground ring-transparent\",\r\n  },\r\n  vertical: {\r\n    shape: \"size-10 origin-left\",\r\n    idle: \"text-muted-foreground hover:bg-foreground/5 hover:text-foreground\",\r\n    active: \"bg-mise-soft text-foreground\",\r\n  },\r\n  labeled: {\r\n    shape: \"h-14 w-full origin-bottom flex-col gap-1 text-xs\",\r\n    idle: \"text-muted-foreground hover:text-foreground\",\r\n    active: \"font-medium text-foreground\",\r\n  },\r\n}\r\n\r\n/** Each mark sits where that layout has room: under the icon, on the rail's edge, behind the icon. A filled tile needs none. */\r\nconst MARK_CLASS: Record<DockVariant, string | null> = {\r\n  glass: \"absolute bottom-0.5 left-1/2 h-[3px] w-3 -translate-x-1/2 rounded-full bg-mise\",\r\n  plain: null,\r\n  vertical: \"absolute top-1/2 -left-2 h-4 w-[3px] -translate-y-1/2 rounded-r-full bg-mise\",\r\n  labeled: \"absolute top-1.5 left-1/2 -z-10 h-7 w-10 -translate-x-1/2 rounded-full bg-mise-soft\",\r\n}\r\n\r\ninterface DockContextValue {\r\n  variant: DockVariant\r\n  effect?: DockEffect\r\n}\r\n\r\nconst DockContext = createContext<DockContextValue>({ variant: \"glass\" })\r\n\r\ntype DockProps = Omit<React.ComponentProps<\"nav\">, \"aria-label\"> & {\r\n  /** Names the landmark and the toolbar inside it. */\r\n  label: string\r\n  /** `glass` a translucent bar, `plain` tiles with no bar, `vertical` a side rail, `labeled` words under every icon. */\r\n  variant?: DockVariant\r\n  /** `magnify` swells items near the pointer or keyboard focus. `bounce` hops an item marked `opens` when it is pressed. */\r\n  effect?: DockEffect\r\n}\r\n\r\nfunction Dock({ label, variant = \"glass\", effect, className, children, ...props }: DockProps) {\r\n  const vertical = variant === \"vertical\"\r\n  const { rootRef, listRef, surfaceRef, fits, handlers } = useDockMagnify({ enabled: effect === \"magnify\", vertical })\r\n  const context = useMemo(() => ({ variant, effect }), [variant, effect])\r\n  return (\r\n    <DockContext.Provider value={context}>\r\n      <nav data-slot=\"dock\" data-variant={variant} data-effect={effect} data-fits={fits ? \"true\" : \"false\"} aria-label={label} className={cn(\"inline-grid max-w-full min-w-0 grid-cols-[minmax(0,1fr)]\", className)} {...props}>\r\n        {/* Does not move, so the pointer is measured against something steady. Reserves what a magnified tile rises into. */}\r\n        <div ref={rootRef} data-slot=\"dock-frame\" className={cn(\"relative isolate grid min-w-0 grid-cols-[minmax(0,1fr)]\", effect === \"magnify\" && (vertical ? \"pr-4\" : \"pt-4\"))} {...handlers}>\r\n          <div className=\"relative grid min-w-0 grid-cols-[minmax(0,1fr)]\">\r\n            <span ref={surfaceRef} aria-hidden=\"true\" data-slot=\"dock-surface\" className={cn(\"pointer-events-none absolute inset-0 -z-10\", SURFACE_CLASS[variant])} />\r\n            <ToolbarPrimitive.Root\r\n              ref={listRef}\r\n              data-slot=\"dock-list\"\r\n              aria-label={label}\r\n              orientation={vertical ? \"vertical\" : \"horizontal\"}\r\n              className={cn(\r\n                LIST_CLASS[variant],\r\n                // A scroller clips in both directions, so it is only one when the row really cannot fit.\r\n                !fits && (vertical ? \"overflow-y-auto\" : \"overflow-x-auto\"),\r\n                !fits && \"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\r\n              )}\r\n            >\r\n              {children}\r\n            </ToolbarPrimitive.Root>\r\n          </div>\r\n        </div>\r\n      </nav>\r\n    </DockContext.Provider>\r\n  )\r\n}\r\n\r\ntype DockItemProps = Omit<React.ComponentProps<\"button\">, \"children\" | \"ref\"> & {\r\n  /** The accessible name, and the tooltip or the visible word depending on the variant. */\r\n  label: string\r\n  /** The current page or app: sets aria-current and draws the variant's mark. */\r\n  active?: boolean\r\n  /** This item opens something, so `bounce` may answer it. Items that only switch a view stay still. */\r\n  opens?: boolean\r\n  /** Renders a link instead of a button. */\r\n  href?: string\r\n  children: React.ReactNode\r\n}\r\n\r\nfunction DockItem({ label, active = false, opens = false, href, className, children, onClick, disabled, ...props }: DockItemProps) {\r\n  const { variant, effect } = useContext(DockContext)\r\n  const reduceMotion = useReducedMotion() ?? false\r\n  const iconRef = useRef<HTMLSpanElement>(null)\r\n  const tile = TILE_CLASS[variant]\r\n  const labeled = variant === \"labeled\"\r\n  const shared = {\r\n    \"data-slot\": \"dock-item\",\r\n    \"aria-current\": active ? (\"page\" as const) : undefined,\r\n    \"aria-label\": labeled ? undefined : label,\r\n    className: cn(TILE_BASE_CLASS, tile.shape, active ? tile.active : tile.idle, className),\r\n  }\r\n  const press: React.MouseEventHandler<HTMLElement> = (event) => {\r\n    onClick?.(event as React.MouseEvent<HTMLButtonElement>)\r\n    if (effect === \"bounce\" && opens && !reduceMotion) hopOnce(iconRef.current, variant === \"vertical\")\r\n  }\r\n  const body = (\r\n    <>\r\n      {active && MARK_CLASS[variant] ? <span aria-hidden=\"true\" data-slot=\"dock-mark\" className={MARK_CLASS[variant] as string} /> : null}\r\n      <span ref={iconRef} aria-hidden=\"true\" data-slot=\"dock-icon\" className={cn(\"inline-flex\", labeled && \"h-7 items-center\")}>\r\n        {children}\r\n      </span>\r\n      {labeled ? <span data-slot=\"dock-label\" className=\"leading-none whitespace-nowrap\">{label}</span> : null}\r\n    </>\r\n  )\r\n  const item = href ? (\r\n    <ToolbarPrimitive.Link {...shared} href={href} onClick={press}>\r\n      {body}\r\n    </ToolbarPrimitive.Link>\r\n  ) : (\r\n    <ToolbarPrimitive.Button {...shared} {...props} type=\"button\" disabled={disabled} onClick={press}>\r\n      {body}\r\n    </ToolbarPrimitive.Button>\r\n  )\r\n  return (\r\n    <span data-slot=\"dock-slot\" className={cn(\"relative flex shrink-0\", labeled && \"isolate w-16 min-w-11 shrink\")}>\r\n      {labeled ? (\r\n        item\r\n      ) : (\r\n        <Tooltip>\r\n          <TooltipTrigger asChild>{item}</TooltipTrigger>\r\n          {/* Follows the tile each frame while open, since a magnified tile moves without any layout change. */}\r\n          <TooltipContent side={variant === \"vertical\" ? \"right\" : \"top\"} sideOffset={8} updatePositionStrategy=\"always\">\r\n            {label}\r\n          </TooltipContent>\r\n        </Tooltip>\r\n      )}\r\n    </span>\r\n  )\r\n}\r\n\r\nexport { Dock, DockItem, type DockProps, type DockItemProps, type DockVariant, type DockEffect }\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/dock-magnify.ts",
      "content": "import type * as React from \"react\"\r\nimport { useCallback, useLayoutEffect, useRef, useState } from \"react\"\r\nimport { useMotionValue, useMotionValueEvent, useReducedMotion, useSpring } from \"motion/react\"\r\nimport { dockHasRoom, dockLayout, dockMaxSpread, type DockSlot } from \"@/registry/miseui/ui/dock-math\"\r\nimport { isFinePointer } from \"@/registry/miseui/ui/pointer-fine\"\r\n\r\n/** Critical damping. useSpring takes physics, not a duration: 0.2 s to follow, 0.35 s to arrive and leave. */\r\nconst FOLLOW_SPRING = { stiffness: 700, damping: 53, mass: 1 }\r\nconst PRESENCE_SPRING = { stiffness: 322, damping: 36, mass: 1 }\r\nconst SLOT_SELECTOR = \"[data-slot='dock-slot']\"\r\nconst SETTLED = 0.002\r\n\r\nfunction matchesFocusVisible(element: Element) {\r\n  try {\r\n    return element.matches(\":focus-visible\")\r\n  } catch {\r\n    return false\r\n  }\r\n}\r\n\r\nfunction readSlots(root: HTMLElement, vertical: boolean): readonly DockSlot[] {\r\n  const box = root.getBoundingClientRect()\r\n  return [...root.querySelectorAll(SLOT_SELECTOR)].map((slot) => {\r\n    const rect = slot.getBoundingClientRect()\r\n    return vertical\r\n      ? { centre: rect.top + rect.height / 2 - box.top, size: rect.height }\r\n      : { centre: rect.left + rect.width / 2 - box.left, size: rect.width }\r\n  })\r\n}\r\n\r\n/** Slots do not move, so this stays true while tiles are magnified. A row that scrolls would clip them, so it never magnifies. */\r\nfunction rowFits(list: HTMLElement, vertical: boolean) {\r\n  const slots = [...list.querySelectorAll(SLOT_SELECTOR)]\r\n  const first = slots[0]?.getBoundingClientRect()\r\n  const last = slots[slots.length - 1]?.getBoundingClientRect()\r\n  if (!first || !last) return true\r\n  const style = getComputedStyle(list)\r\n  const padding = (parseFloat(vertical ? style.paddingTop : style.paddingLeft) || 0) + (parseFloat(vertical ? style.paddingBottom : style.paddingRight) || 0)\r\n  const needed = (vertical ? last.bottom - first.top : last.right - first.left) + padding\r\n  return needed <= (vertical ? list.clientHeight : list.clientWidth) + 1\r\n}\r\n\r\nfunction useRowFits(listRef: React.RefObject<HTMLElement | null>, vertical: boolean) {\r\n  // Starts as a scroller: a server-rendered row too long for its box must not widen the page before hydration.\r\n  const [fits, setFits] = useState(false)\r\n  useLayoutEffect(() => {\r\n    const list = listRef.current\r\n    if (!list) return\r\n    const check = () => setFits(rowFits(list, vertical))\r\n    check()\r\n    if (typeof ResizeObserver === \"undefined\") return\r\n    const observer = new ResizeObserver(check)\r\n    observer.observe(list)\r\n    return () => observer.disconnect()\r\n  }, [listRef, vertical])\r\n  return fits\r\n}\r\n\r\nfunction writeRow(root: HTMLElement, surface: HTMLElement | null, slots: readonly DockSlot[], pointer: number, amount: number, vertical: boolean) {\r\n  const tiles = root.querySelectorAll<HTMLElement>(\"[data-slot='dock-item']\")\r\n  const { scales, shifts, spread } = dockLayout(slots, pointer, amount)\r\n  tiles.forEach((tile, index) => {\r\n    const shift = shifts[index] ?? 0\r\n    tile.style.transform = `${vertical ? \"translateY\" : \"translateX\"}(${shift.toFixed(2)}px) scale(${(scales[index] ?? 1).toFixed(4)})`\r\n  })\r\n  if (!surface) return\r\n  const length = vertical ? surface.offsetHeight : surface.offsetWidth\r\n  if (length > 0) surface.style.transform = `${vertical ? \"scaleY\" : \"scaleX\"}(${(1 + spread / length).toFixed(4)})`\r\n}\r\n\r\n/** No transform at rest, so icons and labels are drawn sharp and the tooltip measures the real box. */\r\nfunction clearRow(root: HTMLElement, surface: HTMLElement | null) {\r\n  root.querySelectorAll<HTMLElement>(\"[data-slot='dock-item']\").forEach((tile) => tile.style.removeProperty(\"transform\"))\r\n  surface?.style.removeProperty(\"transform\")\r\n}\r\n\r\n/** One pair of springs drives the whole row. Every write is a direct style write, so a pointer stream never re-renders. */\r\nfunction useMagnifyRow(rootRef: React.RefObject<HTMLElement | null>, surfaceRef: React.RefObject<HTMLElement | null>, vertical: boolean) {\r\n  const slotsRef = useRef<readonly DockSlot[]>([])\r\n  const pointer = useMotionValue(0)\r\n  const presence = useMotionValue(0)\r\n  const smoothPointer = useSpring(pointer, FOLLOW_SPRING)\r\n  const smoothPresence = useSpring(presence, PRESENCE_SPRING)\r\n\r\n  const write = useCallback(() => {\r\n    const root = rootRef.current\r\n    if (!root) return\r\n    const amount = smoothPresence.get()\r\n    if (amount < SETTLED && presence.get() === 0) clearRow(root, surfaceRef.current)\r\n    else writeRow(root, surfaceRef.current, slotsRef.current, smoothPointer.get(), amount, vertical)\r\n  }, [presence, rootRef, smoothPointer, smoothPresence, surfaceRef, vertical])\r\n\r\n  useMotionValueEvent(smoothPointer, \"change\", write)\r\n  useMotionValueEvent(smoothPresence, \"change\", write)\r\n\r\n  const aim = useCallback(\r\n    (coordinate: number) => {\r\n      const root = rootRef.current\r\n      if (!root) return\r\n      // Arriving from outside, the swell starts under the pointer instead of sweeping across from where it last was.\r\n      if (presence.get() === 0) {\r\n        slotsRef.current = readSlots(root, vertical)\r\n        const box = root.getBoundingClientRect()\r\n        if (!vertical && !dockHasRoom(box.left, box.right, window.innerWidth, dockMaxSpread(slotsRef.current))) return\r\n        smoothPointer.jump(coordinate)\r\n      }\r\n      pointer.set(coordinate)\r\n      presence.set(1)\r\n    },\r\n    [pointer, presence, rootRef, smoothPointer, vertical],\r\n  )\r\n  const release = useCallback(() => presence.set(0), [presence])\r\n  return { aim, release }\r\n}\r\n\r\ninterface DockMagnifyOptions {\r\n  enabled: boolean\r\n  vertical: boolean\r\n}\r\n\r\nexport function useDockMagnify({ enabled, vertical }: DockMagnifyOptions) {\r\n  const rootRef = useRef<HTMLDivElement>(null)\r\n  const listRef = useRef<HTMLDivElement>(null)\r\n  const surfaceRef = useRef<HTMLSpanElement>(null)\r\n  const hoveringRef = useRef(false)\r\n  const reduceMotion = useReducedMotion() ?? false\r\n  const fits = useRowFits(listRef, vertical)\r\n  const { aim, release } = useMagnifyRow(rootRef, surfaceRef, vertical)\r\n  const allowed = enabled && fits && !reduceMotion\r\n\r\n  const handlers = {\r\n    onPointerMove: (event: React.PointerEvent<HTMLElement>) => {\r\n      if (!allowed || event.pointerType === \"touch\" || !isFinePointer()) return\r\n      hoveringRef.current = true\r\n      const box = event.currentTarget.getBoundingClientRect()\r\n      aim(vertical ? event.clientY - box.top : event.clientX - box.left)\r\n    },\r\n    onPointerLeave: (event: React.PointerEvent<HTMLElement>) => {\r\n      hoveringRef.current = false\r\n      const active = event.currentTarget.ownerDocument.activeElement\r\n      if (!active || !event.currentTarget.contains(active) || !matchesFocusVisible(active)) release()\r\n    },\r\n    onFocusCapture: (event: React.FocusEvent<HTMLElement>) => {\r\n      if (!allowed || hoveringRef.current || !isFinePointer() || !matchesFocusVisible(event.target)) return\r\n      const root = event.currentTarget\r\n      const slot = event.target.closest(SLOT_SELECTOR)\r\n      const index = slot ? [...root.querySelectorAll(SLOT_SELECTOR)].indexOf(slot) : -1\r\n      const centre = readSlots(root, vertical)[index]?.centre\r\n      if (centre !== undefined) aim(centre)\r\n    },\r\n    onBlurCapture: (event: React.FocusEvent<HTMLElement>) => {\r\n      if (!hoveringRef.current && !event.currentTarget.contains(event.relatedTarget)) release()\r\n    },\r\n  }\r\n\r\n  return { rootRef, listRef, surfaceRef, fits, handlers }\r\n}\r\n\r\n/** One hop on the icon inside the tile, so it composes with the magnify transform written on the tile. */\r\nexport function hopOnce(icon: HTMLElement | null, vertical: boolean) {\r\n  if (!icon) return\r\n  const lifted = vertical ? \"translateX(10px)\" : \"translateY(-14px)\"\r\n  icon.getAnimations().forEach((animation) => animation.cancel())\r\n  icon.animate(\r\n    [\r\n      { transform: \"none\", easing: \"cubic-bezier(0.16, 1, 0.3, 1)\" },\r\n      { transform: lifted, offset: 0.4, easing: \"cubic-bezier(0.5, 0, 0.75, 0)\" },\r\n      { transform: \"none\" },\r\n    ],\r\n    { duration: 480 },\r\n  )\r\n}\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/dock-math.ts",
      "content": "export interface DockSlot {\n  /** Middle of the resting slot, measured from the wrapper that does not move. */\n  centre: number\n  size: number\n}\n\nexport interface DockShape {\n  maxScale: number\n  /** Distance at which an item stops reacting, in px. */\n  reach: number\n}\n\nexport interface DockLayout {\n  scales: readonly number[]\n  shifts: readonly number[]\n  /** Everything the row has grown by, half on each side. */\n  spread: number\n}\n\n/** 40 px becomes 56 px: enough to read as a lens, small enough to stay in a quiet interface. */\nexport const DOCK_SHAPE: DockShape = { maxScale: 1.4, reach: 96 }\n\n/** A cosine bell: flat under the pointer and flat at the far end, so nothing visibly starts or stops. */\nexport function dockFalloff(distance: number, reach: number) {\n  if (reach <= 0) return 0\n  const progress = Math.abs(distance) / reach\n  if (progress >= 1) return 0\n  return (1 + Math.cos(Math.PI * progress)) / 2\n}\n\n/**\n * Scales alone would overlap neighbours, so each item also moves aside by what the items before it have gained.\n * `scales` and `shifts` come from one pair of values on purpose: per-item springs drift apart and overlap in transit.\n */\nexport function dockLayout(slots: readonly DockSlot[], pointer: number, presence: number, shape: DockShape = DOCK_SHAPE): DockLayout {\n  const scales = slots.map((slot) => 1 + (shape.maxScale - 1) * presence * dockFalloff(pointer - slot.centre, shape.reach))\n  const extras = slots.map((slot, index) => slot.size * ((scales[index] as number) - 1))\n  const spread = extras.reduce((sum, extra) => sum + extra, 0)\n  const shifts = extras.map((extra, index) => {\n    const before = extras.slice(0, index).reduce((sum, value) => sum + value, 0)\n    return before + extra / 2 - spread / 2\n  })\n  return { scales, shifts, spread }\n}\n\n/** The widest the row gets, for asking whether the page has room before the pointer has picked a spot. */\nexport function dockMaxSpread(slots: readonly DockSlot[], shape: DockShape = DOCK_SHAPE) {\n  const middle = slots[Math.floor(slots.length / 2)]\n  return middle ? dockLayout(slots, middle.centre, 1, shape).spread : 0\n}\n\n/** Growth past the window would widen the document, so a row without room stays at rest. */\nexport function dockHasRoom(left: number, right: number, viewportWidth: number, maxSpread: number) {\n  return left >= maxSpread / 2 && viewportWidth - right >= maxSpread / 2\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/pointer-fine.ts",
      "content": "/**\n * Asked when an event arrives, never during render, so a server render hydrates and a laptop with a touch screen\n * answers for the input it is using right now.\n */\nexport function isFinePointer() {\n  return typeof window !== \"undefined\" && window.matchMedia(\"(hover: hover) and (pointer: fine)\").matches\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "navigation"
  ],
  "type": "registry:ui"
}