{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"floating-button-group","type":"registry:component","title":"Floating button group","description":"Floating canvas and viewport toolbar with gliding tool highlight, stepper zoom controls, and FilterBar morphing popover.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/floating-button-group.tsx","type":"registry:component","content":"// Hairline UI · floating-button-group · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { ChevronDown } from \"lucide-react\";\nimport {\n  INTERACTION_FOCUS,\n  useInteractionReducedMotion,\n} from \"./ui-interaction-motion\";\n\nexport interface FloatingToolItem {\n  id: string;\n  label: string;\n  icon: React.ReactNode;\n  shortcut?: string;\n  active?: boolean;\n  onClick?: () => void;\n  disabled?: boolean;\n}\n\nexport interface FloatingMenuItem {\n  id: string;\n  label: string;\n  icon?: React.ReactNode;\n  destructive?: boolean;\n  onSelect: () => void;\n}\n\nexport interface FloatingButtonGroupProps\n  extends React.HTMLAttributes<HTMLDivElement> {\n  tools: FloatingToolItem[];\n  activeId?: string;\n  onToolSelect?: (id: string) => void;\n  /** Stepper zoom or counter value (e.g. 100) */\n  zoomValue?: number;\n  onZoomChange?: (newZoom: number) => void;\n  /** Optional secondary menu items opened by trailing chevron */\n  menuItems?: FloatingMenuItem[];\n  /** Positioning mode */\n  dock?: \"inline\" | \"bottom\" | \"top\";\n  label?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16, 1, 0.3, 1)\";\nconst UI = \"cubic-bezier(0.23, 1, 0.32, 1)\";\nconst OPEN = 280;\nconst CLOSE = 200;\nconst ROW_H = 32;\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\ntype Box = { left: number; top: number; width: number; height: number };\n\nexport function FloatingButtonGroup({\n  tools,\n  activeId: controlledActiveId,\n  onToolSelect,\n  zoomValue,\n  onZoomChange,\n  menuItems,\n  dock = \"inline\",\n  label = \"Floating toolbar\",\n  className = \"\",\n  style,\n  ...props\n}: FloatingButtonGroupProps) {\n  const reduced = useInteractionReducedMotion();\n  const [internalActiveId, setInternalActiveId] = React.useState<string>(\n    tools[0]?.id ?? \"\"\n  );\n  const [hoveredId, setHoveredId] = React.useState<string | null>(null);\n  const [focusedId, setFocusedId] = React.useState<string | null>(null);\n\n  // FilterBar-style morphing dropdown state\n  const [phase, setPhase] = React.useState<Phase>(\"closed\");\n  const [box, setBox] = React.useState<Box>({ left: 0, top: 0, width: 0, height: 36 });\n  const [panelH, setPanelH] = React.useState(0);\n  const [menuActiveIndex, setMenuActiveIndex] = React.useState(0);\n\n  const containerRef = React.useRef<HTMLDivElement | null>(null);\n  const triggerRef = React.useRef<HTMLButtonElement | null>(null);\n  const panelRef = React.useRef<HTMLDivElement | null>(null);\n  const timer = React.useRef<number>(0);\n\n  const activeTool = controlledActiveId !== undefined ? controlledActiveId : internalActiveId;\n  const currentHighlightId = hoveredId ?? focusedId ?? activeTool;\n\n  // Gliding highlight rect\n  const [highlightRect, setHighlightRect] = React.useState<{\n    x: number;\n    y: number;\n    width: number;\n    height: number;\n    visible: boolean;\n  }>({ x: 0, y: 0, width: 0, height: 0, visible: false });\n\n  // Update gliding highlight over active/hovered tool\n  React.useLayoutEffect(() => {\n    if (!containerRef.current || !currentHighlightId) {\n      setHighlightRect((p) => ({ ...p, visible: false }));\n      return;\n    }\n\n    const el = containerRef.current.querySelector<HTMLElement>(\n      `[data-tool-id=\"${currentHighlightId}\"]`\n    );\n    if (!el) {\n      setHighlightRect((p) => ({ ...p, visible: false }));\n      return;\n    }\n\n    const cRect = containerRef.current.getBoundingClientRect();\n    const eRect = el.getBoundingClientRect();\n\n    setHighlightRect({\n      x: eRect.left - cRect.left,\n      y: eRect.top - cRect.top,\n      width: eRect.width,\n      height: eRect.height,\n      visible: true,\n    });\n  }, [currentHighlightId, tools]);\n\n  // Clean dropdown timer\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  // FilterBar double-rAF expansion\n  React.useEffect(() => {\n    if (phase !== \"opening\") return;\n    let inner = 0;\n    const outer = requestAnimationFrame(() => {\n      inner = requestAnimationFrame(() => setPhase(\"open\"));\n    });\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [phase]);\n\n  // Measure dropdown panel height\n  React.useLayoutEffect(() => {\n    const panel = panelRef.current;\n    if (!panel) return;\n    const measure = () => setPanelH(panel.offsetHeight);\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(panel);\n    return () => ro.disconnect();\n  }, [menuItems, phase]);\n\n  const openDropdown = () => {\n    const wrap = containerRef.current;\n    const trigger = triggerRef.current;\n    if (!wrap || !trigger) return;\n    window.clearTimeout(timer.current);\n    const a = trigger.getBoundingClientRect();\n    const w = wrap.getBoundingClientRect();\n    setBox({\n      left: a.left - w.left,\n      top: a.top - w.top,\n      width: a.width,\n      height: a.height,\n    });\n    setMenuActiveIndex(0);\n    setPhase(reduced ? \"open\" : \"opening\");\n  };\n\n  const closeDropdown = React.useCallback(\n    (refocus = true) => {\n      window.clearTimeout(timer.current);\n      if (refocus) triggerRef.current?.focus({ preventScroll: true });\n      if (reduced) {\n        setPhase(\"closed\");\n        return;\n      }\n      setPhase(\"closing\");\n      timer.current = window.setTimeout(() => setPhase(\"closed\"), CLOSE);\n    },\n    [reduced]\n  );\n\n  // Close dropdown on outside pointer\n  React.useEffect(() => {\n    if (phase === \"closed\" || phase === \"closing\") return;\n    const onDown = (e: PointerEvent) => {\n      if (!containerRef.current?.contains(e.target as Node)) {\n        closeDropdown(false);\n      }\n    };\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") closeDropdown(true);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    document.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onDown);\n      document.removeEventListener(\"keydown\", onKey);\n    };\n  }, [phase, closeDropdown]);\n\n  // Handle keyboard arrows across tools or inside menu\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (phase === \"open\") {\n      if (!menuItems?.length) return;\n      if (e.key === \"ArrowDown\") {\n        e.preventDefault();\n        setMenuActiveIndex((i) => (i < menuItems.length - 1 ? i + 1 : 0));\n      } else if (e.key === \"ArrowUp\") {\n        e.preventDefault();\n        setMenuActiveIndex((i) => (i > 0 ? i - 1 : menuItems.length - 1));\n      } else if (e.key === \"Enter\" || e.key === \" \") {\n        e.preventDefault();\n        const item = menuItems[menuActiveIndex];\n        if (item) {\n          closeDropdown(true);\n          item.onSelect();\n        }\n      }\n      return;\n    }\n\n    const toolEls = Array.from(\n      containerRef.current?.querySelectorAll<HTMLButtonElement>(\"button[data-tool-id]\") ?? []\n    );\n    const currentIndex = toolEls.findIndex((el) => el === document.activeElement);\n    if (currentIndex < 0) return;\n\n    let next = -1;\n    if (e.key === \"ArrowRight\") next = currentIndex < toolEls.length - 1 ? currentIndex + 1 : 0;\n    else if (e.key === \"ArrowLeft\") next = currentIndex > 0 ? currentIndex - 1 : toolEls.length - 1;\n\n    if (next >= 0) {\n      e.preventDefault();\n      toolEls[next]?.focus();\n      const nextTool = tools[next];\n      if (nextTool) {\n        if (controlledActiveId === undefined) setInternalActiveId(nextTool.id);\n        onToolSelect?.(nextTool.id);\n      }\n    }\n  };\n\n  const handleToolClick = (tool: FloatingToolItem) => {\n    if (tool.disabled) return;\n    if (controlledActiveId === undefined) setInternalActiveId(tool.id);\n    onToolSelect?.(tool.id);\n    tool.onClick?.();\n  };\n\n  const dockClass =\n    dock === \"bottom\"\n      ? \"fixed bottom-6 left-1/2 -translate-x-1/2 z-40\"\n      : dock === \"top\"\n      ? \"fixed top-6 left-1/2 -translate-x-1/2 z-40\"\n      : \"relative inline-flex\";\n\n  const mounted = phase !== \"closed\";\n  const expanded = phase === \"open\";\n  const MENU_W = 180;\n  const targetLeft = Math.max(0, box.left + box.width - MENU_W);\n  const targetTop = box.top + box.height + 6;\n\n  return (\n    <div\n      ref={containerRef}\n      role=\"toolbar\"\n      aria-label={label}\n      onKeyDown={handleKeyDown}\n      style={style}\n      className={`${dockClass} items-center rounded-full p-1 gap-1 select-none isolate bg-card/90 backdrop-blur-md text-foreground shadow-[inset_0_0_0_1px_var(--border)] max-w-[calc(100vw-32px)] overflow-visible ${className}`}\n      {...props}\n    >\n      {/* Gliding Active Indicator */}\n      <span\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute z-0 rounded-full bg-accent transition-all duration-180 motion-reduce:transition-none\"\n        style={{\n          transform: `translate3d(${highlightRect.x}px, ${highlightRect.y}px, 0)`,\n          width: highlightRect.width ? `${highlightRect.width}px` : 0,\n          height: highlightRect.height ? `${highlightRect.height}px` : 0,\n          opacity: highlightRect.visible ? 1 : 0,\n          transition: reduced\n            ? \"none\"\n            : `transform 180ms ${UI}, width 180ms ${UI}, height 180ms ${UI}, opacity 140ms ${EASE}`,\n        }}\n      />\n\n      {/* Main Tools Row */}\n      {tools.map((tool) => {\n        const isActive = activeTool === tool.id;\n\n        return (\n          <button\n            key={tool.id}\n            type=\"button\"\n            data-tool-id={tool.id}\n            disabled={tool.disabled}\n            aria-label={tool.shortcut ? `${tool.label} (${tool.shortcut})` : tool.label}\n            aria-pressed={isActive}\n            onClick={() => handleToolClick(tool)}\n            onPointerEnter={() => !tool.disabled && setHoveredId(tool.id)}\n            onPointerLeave={() => setHoveredId(null)}\n            onFocus={() => !tool.disabled && setFocusedId(tool.id)}\n            onBlur={() => setFocusedId(null)}\n            className={`relative z-10 flex size-8 items-center justify-center rounded-full cursor-pointer transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 active:scale-[0.96] ${\n              isActive ? \"text-foreground font-semibold\" : \"text-muted-foreground hover:text-foreground\"\n            } ${INTERACTION_FOCUS}`}\n          >\n            <span className=\"size-4 shrink-0 inline-flex items-center justify-center\">\n              {tool.icon}\n            </span>\n          </button>\n        );\n      })}\n\n      {/* Optional Stepper / Zoom controls */}\n      {zoomValue !== undefined && onZoomChange && (\n        <>\n          <span aria-hidden=\"true\" className=\"w-px h-4 bg-border shrink-0 mx-0.5\" />\n          <div className=\"flex items-center gap-0.5 text-[12px] font-medium tabular-nums text-muted-foreground px-1\">\n            <button\n              type=\"button\"\n              aria-label=\"Zoom out\"\n              onClick={() => onZoomChange(Math.max(25, zoomValue - 10))}\n              className={`flex size-6 items-center justify-center rounded-full hover:bg-accent hover:text-foreground cursor-pointer ${INTERACTION_FOCUS}`}\n            >\n              -\n            </button>\n            <span className=\"min-w-[42px] text-center font-mono text-[11.5px] text-foreground\">\n              {zoomValue}%\n            </span>\n            <button\n              type=\"button\"\n              aria-label=\"Zoom in\"\n              onClick={() => onZoomChange(Math.min(300, zoomValue + 10))}\n              className={`flex size-6 items-center justify-center rounded-full hover:bg-accent hover:text-foreground cursor-pointer ${INTERACTION_FOCUS}`}\n            >\n              +\n            </button>\n          </div>\n        </>\n      )}\n\n      {/* Optional Dropdown Menu Trigger */}\n      {menuItems && menuItems.length > 0 && (\n        <>\n          <span aria-hidden=\"true\" className=\"w-px h-4 bg-border shrink-0 mx-0.5\" />\n          <button\n            ref={triggerRef}\n            type=\"button\"\n            aria-haspopup=\"menu\"\n            aria-expanded={mounted && phase !== \"closing\"}\n            aria-label=\"Toolbar options\"\n            onClick={(e) => {\n              e.stopPropagation();\n              if (mounted && phase !== \"closing\") closeDropdown();\n              else openDropdown();\n            }}\n            className={`relative z-10 flex size-8 items-center justify-center rounded-full cursor-pointer text-muted-foreground hover:text-foreground transition-colors ${INTERACTION_FOCUS}`}\n          >\n            <ChevronDown\n              size={13}\n              strokeWidth={1.75}\n              style={{\n                transform: mounted && phase !== \"closing\" ? \"rotate(180deg)\" : \"none\",\n                transition: reduced ? \"none\" : `transform 300ms ${EASE}`,\n              }}\n            />\n          </button>\n        </>\n      )}\n\n      {/* FilterBar Morphing Dropdown Panel */}\n      {mounted && menuItems && menuItems.length > 0 && (\n        <div\n          role=\"menu\"\n          className=\"absolute z-50 overflow-hidden bg-popover text-popover-foreground\"\n          style={{\n            top: targetTop,\n            left: expanded ? targetLeft : box.left,\n            width: expanded ? MENU_W : box.width,\n            height: expanded ? panelH : box.height,\n            borderRadius: expanded ? 12 : box.height / 2,\n            opacity: expanded ? 1 : 0,\n            boxShadow: \"0 0 0 1px var(--border)\",\n            transition: reduced\n              ? \"none\"\n              : phase === \"closing\"\n                ? `width ${CLOSE}ms ${UI}, height ${CLOSE}ms ${UI}, left ${CLOSE}ms ${UI}, border-radius ${CLOSE}ms ${UI}, opacity ${CLOSE}ms ${UI}`\n                : `width ${OPEN}ms ${UI}, height ${OPEN}ms ${UI}, left ${OPEN}ms ${UI}, border-radius ${OPEN}ms ${UI}, opacity 160ms ${UI}`,\n          }}\n        >\n          <div\n            ref={panelRef}\n            inert={!expanded ? true : undefined}\n            className=\"relative p-1\"\n            style={{\n              width: MENU_W,\n              opacity: expanded ? 1 : 0,\n              transition: reduced\n                ? \"none\"\n                : expanded\n                  ? `opacity 180ms ${EASE} 60ms`\n                  : \"opacity 90ms ease-out\",\n            }}\n          >\n            {/* Gliding Row Highlight inside Menu */}\n            <span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-x-1 top-1 h-8 rounded-[7px] bg-accent\"\n              style={{\n                transform: `translateY(${menuActiveIndex * ROW_H}px)`,\n                opacity: menuActiveIndex >= 0 ? 1 : 0,\n                transition: reduced ? \"none\" : `transform 160ms ${UI}`,\n              }}\n            />\n\n            {menuItems.map((item, index) => (\n              <button\n                key={item.id}\n                type=\"button\"\n                role=\"menuitem\"\n                onPointerEnter={() => setMenuActiveIndex(index)}\n                onClick={() => {\n                  closeDropdown(false);\n                  item.onSelect();\n                }}\n                className={`relative z-10 flex h-8 w-full items-center gap-2 rounded-[7px] px-2.5 text-[13px] font-medium transition-colors cursor-pointer select-none text-left ${\n                  item.destructive\n                    ? menuActiveIndex === index\n                      ? \"text-red-600 dark:text-red-400 font-semibold\"\n                      : \"text-red-500/90\"\n                    : menuActiveIndex === index\n                      ? \"text-foreground font-semibold\"\n                      : \"text-muted-foreground\"\n                } ${INTERACTION_FOCUS}`}\n              >\n                {item.icon && (\n                  <span className=\"inline-flex size-3.5 shrink-0 items-center justify-center text-muted-foreground\">\n                    {item.icon}\n                  </span>\n                )}\n                <span className=\"truncate flex-1\">{item.label}</span>\n              </button>\n            ))}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default FloatingButtonGroup;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/floating-button-group. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}