Selection toolbar that rises above highlighted text via getSelection anchoring, flips at viewport edges, and waits out drag-selection.
npx shadcn@latest add @paragon/floating-toolbar"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}
/>
);
}