Components

Getting started

Components121

More

Floating canvas and viewport toolbar with gliding tool highlight, stepper zoom controls, and FilterBar morphing popover.

Used forCanvas & design editorsDocument & PDF viewersInteractive map / 3D viewer

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/floating-button-group.json

Where it’s used

  • Canvas & design editors

    Figma-style bottom dock with tool selection (Select, Hand, Frame, Text).

  • Document & PDF viewers

    Floating overlay with zoom controls and export actions.

  • Interactive map / 3D viewer

    Persistent floating control bar anchored to bottom-center of viewport.

What it handles for you

Floating bars often suffer from harsh shadows, blurry popups, or poor mobile responsiveness. This component delivers glassmorphic elegance with pure 1px borders and organic popover transitions.

Instead of wiring upclunky floating divsgeneric shadcn toolbar dockheavy modal popovers
  1. Gliding active indicator

    A quiet highlight glides underneath the active and hovered tool with UI easing.

  2. Zoom & stepper controls

    Built-in stepper segment with hairline dividers and tabular numeric formatting.

  3. FilterBar morphing menu

    Trailing chevron opens an options panel that physically grows out of the trigger button.

  4. Keyboard shortcuts

    Displays hotkeys and handles full arrow-key and Escape accessibility.

Overview

A floating toolbar capsule designed for canvas environments, interactive preview viewers, and overlay toolsets. Features backdrop blur, 1px quiet hairlines, a gliding active indicator, integrated zoom steppers, and an organic morphing options menu that grows directly from the chevron trigger.

States

idle
Resting glassmorphic pill with active tool highlighted.
hovered
Gliding highlight tracks pointer across tools.
menuOpen
Secondary options panel morphs out from under the chevron trigger.

Usage

usage.tsx
import { FloatingButtonGroup } from "@/components/ui/floating-button-group";import { MousePointer, Hand, Square, Type } from "lucide-react";<FloatingButtonGroup  dock="inline"  tools={[    { id: "select", label: "Select", icon: <MousePointer size={14} />, shortcut: "V" },    { id: "hand", label: "Hand", icon: <Hand size={14} />, shortcut: "H" },    { id: "frame", label: "Frame", icon: <Square size={14} />, shortcut: "F" },    { id: "text", label: "Text", icon: <Type size={14} />, shortcut: "T" },  ]}  zoomValue={100}  onZoomChange={(zoom) => console.log(zoom)}/>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Props

PropTypeDescription
toolsFloatingToolItem[]List of tools with id, label, icon, shortcut, and click handler.
activeIdstringActive tool identifier.
onToolSelect(id: string) => voidCallback fired when a tool is chosen.
zoomValuenumberOptional zoom value percentage for stepper controls.
menuItemsFloatingMenuItem[]Optional dropdown actions opened by chevron trigger.
dock"inline" | "bottom" | "top"default "inline"Positioning layout.

Accessibility and motion

  • Uses backdrop-blur-md with 1px inset hairline border for modern glassmorphic look.
  • Dropdown menu uses double-rAF expansion directly matched to FilterBar.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

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

Registry item: /r/floating-button-group.json