Floating Toolbar
Navigation

Floating Toolbar

Selection toolbar that rises above highlighted text via getSelection anchoring, flips at viewport edges, and waits out drag-selection.

Install

npx shadcn@latest add @paragon/floating-toolbar

floating-toolbar.tsx

"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface FloatingToolbarProps {
  /** Element whose text selections summon the toolbar. */
  containerRef: React.RefObject<HTMLElement | null>;
  /** Gap in px between the selection and the toolbar. */
  offset?: number;
  /** Accessible name for the toolbar. */
  "aria-label"?: string;
  className?: string;
  children: React.ReactNode;
}

/**
 * Selection toolbar. Listens to `selectionchange`, anchors above the first
 * line of the selection (below the last line when there is no headroom —
 * the enter scales from the edge facing the text), waits for pointer-up
 * during drag-selection, tracks scroll/resize imperatively, and clamps to
 * the viewport. Escape dismisses without touching the selection.
 */
export function FloatingToolbar({
  containerRef,
  offset = 8,
  className,
  children,
  ...props
}: FloatingToolbarProps) {
  const reducedMotion = useReducedMotion();
  const [mounted, setMounted] = React.useState(false);
  const [visible, setVisible] = React.useState(false);
  const [side, setSide] = React.useState<"top" | "bottom">("top");
  const [tick, setTick] = React.useState(0);

  const wrapperRef = React.useRef<HTMLDivElement>(null);
  const toolbarRef = React.useRef<HTMLDivElement>(null);
  const rectsRef = React.useRef<{ first: DOMRect; last: DOMRect } | null>(null);
  const draggingRef = React.useRef(false);
  const frameRef = React.useRef(0);

  React.useEffect(() => setMounted(true), []);

  /** Read the current selection into rectsRef; returns false when hidden. */
  const readSelection = React.useCallback(() => {
    const container = containerRef.current;
    const selection = window.getSelection();
    if (
      !container ||
      !selection ||
      selection.rangeCount === 0 ||
      selection.isCollapsed
    ) {
      return false;
    }
    const range = selection.getRangeAt(0);
    const node = range.commonAncestorContainer;
    const element = node instanceof Element ? node : node.parentElement;
    if (!element || !container.contains(element)) return false;
    const rects = Array.from(range.getClientRects()).filter(
      (rect) => rect.width > 0 || rect.height > 0,
    );
    const fallback = range.getBoundingClientRect();
    const first = rects[0] ?? fallback;
    const last = rects[rects.length - 1] ?? fallback;
    if (first.width === 0 && first.height === 0) return false;
    rectsRef.current = { first, last };
    return true;
  }, [containerRef]);

  /** Place the wrapper — pure DOM writes, safe to run every frame. */
  const position = React.useCallback(() => {
    const wrapper = wrapperRef.current;
    const toolbar = toolbarRef.current;
    const rects = rectsRef.current;
    if (!wrapper || !toolbar || !rects) return;
    const width = toolbar.offsetWidth;
    const height = toolbar.offsetHeight;
    const fitsAbove = rects.first.top - offset - height >= 8;
    const nextSide = fitsAbove ? "top" : "bottom";
    setSide(nextSide);
    const rect = nextSide === "top" ? rects.first : rects.last;
    const center = rect.left + rect.width / 2;
    const x = Math.round(
      Math.min(Math.max(center - width / 2, 8), window.innerWidth - 8 - width),
    );
    const y = Math.round(
      nextSide === "top" ? rect.top - offset - height : rect.bottom + offset,
    );
    wrapper.style.transform = `translate3d(${x}px, ${y}px, 0)`;
  }, [offset]);

  // Show/hide on selection change; during drag-selection wait for pointer-up.
  React.useEffect(() => {
    const update = () => {
      if (draggingRef.current) return;
      if (readSelection()) {
        setVisible(true);
        setTick((t) => t + 1);
      } else {
        setVisible(false);
      }
    };
    const onPointerDown = (event: Event) => {
      const container = containerRef.current;
      if (container && container.contains(event.target as Node)) {
        draggingRef.current = true;
      }
    };
    const onPointerUp = () => {
      if (!draggingRef.current) return;
      draggingRef.current = false;
      update();
    };
    document.addEventListener("selectionchange", update);
    document.addEventListener("pointerdown", onPointerDown);
    document.addEventListener("pointerup", onPointerUp);
    return () => {
      document.removeEventListener("selectionchange", update);
      document.removeEventListener("pointerdown", onPointerDown);
      document.removeEventListener("pointerup", onPointerUp);
    };
  }, [containerRef, readSelection]);

  // Position before paint whenever the toolbar (re)appears or re-anchors.
  React.useLayoutEffect(() => {
    if (visible) position();
  }, [visible, tick, side, position]);

  // Track scroll/resize imperatively — no re-render per frame.
  React.useEffect(() => {
    if (!visible) return;
    const reposition = () => {
      cancelAnimationFrame(frameRef.current);
      frameRef.current = requestAnimationFrame(() => {
        if (readSelection()) position();
        else setVisible(false);
      });
    };
    window.addEventListener("scroll", reposition, { capture: true, passive: true });
    window.addEventListener("resize", reposition);
    return () => {
      cancelAnimationFrame(frameRef.current);
      window.removeEventListener("scroll", reposition, { capture: true });
      window.removeEventListener("resize", reposition);
    };
  }, [visible, readSelection, position]);

  // Escape dismisses without clearing the selection.
  React.useEffect(() => {
    if (!visible) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") setVisible(false);
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [visible]);

  // Roving focus across toolbar buttons.
  const onToolbarKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (!["ArrowRight", "ArrowLeft", "Home", "End"].includes(event.key)) return;
    const toolbar = toolbarRef.current;
    if (!toolbar) return;
    const buttons = Array.from(
      toolbar.querySelectorAll<HTMLButtonElement>("button:not(:disabled)"),
    );
    if (buttons.length === 0) return;
    const current = buttons.indexOf(document.activeElement as HTMLButtonElement);
    let next = 0;
    if (event.key === "ArrowRight") next = (current + 1) % buttons.length;
    if (event.key === "ArrowLeft")
      next = (current - 1 + buttons.length) % buttons.length;
    if (event.key === "End") next = buttons.length - 1;
    buttons[next]?.focus();
    event.preventDefault();
  };

  if (!mounted) return null;

  return createPortal(
    <div
      ref={wrapperRef}
      data-slot="floating-toolbar-anchor"
      className="pointer-events-none fixed top-0 left-0 z-50"
    >
      <AnimatePresence>
        {visible && (
          <motion.div
            ref={toolbarRef}
            role="toolbar"
            aria-label={props["aria-label"] ?? "Text formatting"}
            aria-orientation="horizontal"
            data-slot="floating-toolbar"
            data-side={side}
            onKeyDown={onToolbarKeyDown}
            initial={{
              opacity: 0,
              scale: reducedMotion ? 1 : 0.97,
              y: reducedMotion ? 0 : side === "top" ? 4 : -4,
              filter: "blur(4px)",
            }}
            animate={{ opacity: 1, scale: 1, y: 0, filter: "blur(0px)" }}
            exit={{
              opacity: 0,
              scale: reducedMotion ? 1 : 0.99,
              filter: "blur(4px)",
              transition: { duration: 0.1, ease: [0.4, 0, 1, 1] },
            }}
            transition={{ type: "spring", duration: 0.3, bounce: 0 }}
            style={{
              transformOrigin: side === "top" ? "bottom center" : "top center",
            }}
            className={cn(
              "pointer-events-auto flex items-center gap-0.5 rounded-lg bg-popover p-1 text-popover-foreground shadow-overlay",
              className,
            )}
          >
            {children}
          </motion.div>
        )}
      </AnimatePresence>
    </div>,
    document.body,
  );
}

export interface FloatingToolbarButtonProps
  extends React.ComponentProps<"button"> {
  /**
   * Marks the action as a toggle and whether it is applied (aria-pressed).
   * Leave undefined for momentary actions.
   */
  active?: boolean;
}

export function FloatingToolbarButton({
  active,
  className,
  ...props
}: FloatingToolbarButtonProps) {
  return (
    <button
      type="button"
      data-slot="floating-toolbar-button"
      aria-pressed={active}
      // Keep the text selection alive while clicking toolbar actions.
      onPointerDown={(event) => event.preventDefault()}
      className={cn(
        "relative flex h-7 min-w-7 items-center justify-center gap-1.5 rounded-md px-1.5 text-[13px] font-medium text-muted-foreground outline-none select-none",
        "transition-[background-color,color,scale] duration-150 ease-out active:not-disabled:scale-[0.97]",
        "hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
        "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
        "aria-pressed:bg-accent aria-pressed:text-foreground",
        "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
        className,
      )}
      {...props}
    />
  );
}

export function FloatingToolbarSeparator({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      aria-hidden
      data-slot="floating-toolbar-separator"
      className={cn("mx-0.5 h-5 w-px shrink-0 bg-border", className)}
      {...props}
    />
  );
}