Selection Toolbar
A floating toolbar that appears on text selection with formatting and utility actions.
"use client";
import { Bold, Copy, Italic, Link, Share2 } from "lucide-react";
import * as React from "react";
import {
SelectionToolbar,
SelectionToolbarItem,
SelectionToolbarSeparator,
} from "@/components/ui/selection-toolbar";
export function SelectionToolbarDemo() {
const containerRef = React.useRef<HTMLDivElement>(null);
const wrapSelection = React.useCallback((tagName: string) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const selectedText = range.toString();
if (!selectedText) return;
const wrapper = document.createElement(tagName);
try {
range.surroundContents(wrapper);
// Re-select the wrapped content to allow multiple formatting actions
selection.removeAllRanges();
const newRange = document.createRange();
newRange.selectNodeContents(wrapper);
selection.addRange(newRange);
} catch {
// Fallback: extract, wrap, and insert
wrapper.textContent = selectedText;
range.deleteContents();
range.insertNode(wrapper);
// Re-select the wrapped content
selection.removeAllRanges();
const newRange = document.createRange();
newRange.selectNodeContents(wrapper);
selection.addRange(newRange);
}
}, []);
const onBold = React.useCallback(() => {
wrapSelection("strong");
console.log({ action: "bold" });
}, [wrapSelection]);
const onItalic = React.useCallback(() => {
wrapSelection("em");
console.log({ action: "italic" });
}, [wrapSelection]);
const onLink = React.useCallback(() => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const url = prompt("Enter URL:");
if (!url) return;
const range = selection.getRangeAt(0);
const link = document.createElement("a");
link.href = url;
link.className = "text-primary underline hover:text-primary/80";
try {
range.surroundContents(link);
// Re-select the linked content
selection.removeAllRanges();
const newRange = document.createRange();
newRange.selectNodeContents(link);
selection.addRange(newRange);
} catch {
link.textContent = range.toString();
range.deleteContents();
range.insertNode(link);
// Re-select the linked content
selection.removeAllRanges();
const newRange = document.createRange();
newRange.selectNodeContents(link);
selection.addRange(newRange);
}
console.log({ action: "link", url });
}, []);
const onCopy = React.useCallback((text: string) => {
navigator.clipboard.writeText(text);
console.log({ action: "copy", text });
// Clear selection to close the toolbar
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
}
}, []);
const onShare = React.useCallback((text: string) => {
if (navigator.share) {
navigator.share({ text });
}
console.log({ action: "share", text });
// Clear selection to close the toolbar
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
}
}, []);
return (
<div className="flex min-h-[400px] w-full items-center justify-center">
<div
ref={containerRef}
contentEditable
suppressContentEditableWarning
className="max-w-2xl space-y-4 rounded-lg border bg-card p-8 text-card-foreground outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<h2 className="font-semibold text-2xl">Medium-Style Text Selection</h2>
<p className="text-muted-foreground leading-relaxed">
Select any text in this area to see the floating toolbar appear. The
toolbar automatically positions itself above the selection and
includes common formatting options like bold, italic, and link, as
well as utility actions like copy and share.
</p>
<p className="text-muted-foreground leading-relaxed">
Try selecting text across multiple lines or near the edges of the
viewport. The menu will automatically adjust its position to stay
visible and accessible. This creates a seamless editing experience
similar to popular writing platforms.
</p>
<SelectionToolbar container={containerRef}>
<SelectionToolbarItem onSelect={onBold}>
<Bold />
</SelectionToolbarItem>
<SelectionToolbarItem onSelect={onItalic}>
<Italic />
</SelectionToolbarItem>
<SelectionToolbarItem onSelect={onLink}>
<Link />
</SelectionToolbarItem>
<SelectionToolbarSeparator />
<SelectionToolbarItem onSelect={onCopy}>
<Copy />
</SelectionToolbarItem>
<SelectionToolbarItem onSelect={onShare}>
<Share2 />
</SelectionToolbarItem>
</SelectionToolbar>
</div>
</div>
);
}Installation
CLI
npx shadcn@latest add @diceui/selection-toolbarManual
Install the following dependencies:
npm install @floating-ui/react-dom @radix-ui/react-slotCopy and paste the following utility into your lib directory.
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/compose-refs.tsx
*/
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
return ref(value);
}
if (ref !== null && ref !== undefined) {
ref.current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return (node) => {
let hasCleanup = false;
const cleanups = refs.map((ref) => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === "function") {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === "function") {
cleanup();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };Copy and paste the following hooks into your hooks directory.
import * as React from "react";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
export { useAsRef };import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };import * as React from "react";
function useLazyRef<T>(fn: () => T) {
const ref = React.useRef<T | null>(null);
if (ref.current === null) {
ref.current = fn();
}
return ref as React.RefObject<T>;
}
export { useLazyRef };Copy and paste the following code into your project.
"use client";
import {
autoUpdate,
flip,
hide,
limitShift,
type Middleware,
offset,
type Placement,
shift,
size,
useFloating,
} from "@floating-ui/react-dom";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import * as ReactDOM from "react-dom";
import { Button } from "@/components/ui/button";
import { useComposedRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { useAsRef } from "@/components/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/components/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/components/hooks/use-lazy-ref";
const ROOT_NAME = "SelectionToolbar";
const ITEM_NAME = "SelectionToolbarItem";
const SIDE_OPTIONS = ["top", "right", "bottom", "left"] as const;
const ALIGN_OPTIONS = ["start", "center", "end"] as const;
type Side = (typeof SIDE_OPTIONS)[number];
type Align = (typeof ALIGN_OPTIONS)[number];
type Boundary = Element | null;
interface DivProps extends React.ComponentProps<"div"> {
asChild?: boolean;
}
type ItemElement = React.ComponentRef<typeof SelectionToolbarItem>;
function getSideAndAlignFromPlacement(placement: Placement) {
const [side, align = "center"] = placement.split("-");
return [side as Side, align as Align] as const;
}
function isNotNull<T>(value: T | null): value is T {
return value !== null;
}
interface SelectionRect {
top: number;
left: number;
width: number;
height: number;
}
interface StoreState {
open: boolean;
selectedText: string;
selectionRect: SelectionRect | null;
}
interface Store {
subscribe: (callback: () => void) => () => void;
getState: () => StoreState;
setState: <K extends keyof StoreState>(key: K, value: StoreState[K]) => void;
notify: () => void;
batch: (fn: () => void) => void;
}
const StoreContext = React.createContext<Store | null>(null);
function useStoreContext(consumerName: string) {
const context = React.useContext(StoreContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
function useStore<T>(
selector: (state: StoreState) => T,
ogStore?: Store | null,
): T {
const contextStore = React.useContext(StoreContext);
const store = ogStore ?? contextStore;
if (!store) {
throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``);
}
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface SelectionToolbarProps extends DivProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onSelectionChange?: (text: string) => void;
container?: HTMLElement | React.RefObject<HTMLElement | null> | null;
portalContainer?: Element | DocumentFragment | null;
side?: Side;
sideOffset?: number;
align?: Align;
alignOffset?: number;
avoidCollisions?: boolean;
collisionBoundary?: Boundary | Boundary[];
collisionPadding?: number | Partial<Record<Side, number>>;
sticky?: "partial" | "always";
hideWhenDetached?: boolean;
updatePositionStrategy?: "optimized" | "always";
}
function SelectionToolbar(props: SelectionToolbarProps) {
const {
open: openProp,
onOpenChange,
onSelectionChange,
container: containerProp,
portalContainer: portalContainerProp,
side = "top",
sideOffset = 8,
align = "center",
alignOffset = 0,
avoidCollisions = true,
collisionBoundary = [],
collisionPadding: collisionPaddingProp = 0,
sticky = "partial",
hideWhenDetached = false,
updatePositionStrategy = "optimized",
className,
style,
asChild,
...rootProps
} = props;
const listenersRef = useLazyRef(() => new Set<() => void>());
const stateRef = useLazyRef<StoreState>(() => ({
open: openProp ?? false,
selectedText: "",
selectionRect: null,
}));
const propsRef = useAsRef({
onOpenChange,
onSelectionChange,
});
const getContainer = React.useCallback((): HTMLElement | null => {
if (containerProp === undefined || containerProp === null) return null;
if (typeof containerProp === "object" && "current" in containerProp) {
return containerProp.current;
}
return containerProp;
}, [containerProp]);
const store = React.useMemo<Store>(() => {
let isBatching = false;
return {
subscribe: (callback) => {
listenersRef.current.add(callback);
return () => listenersRef.current.delete(callback);
},
getState: () => stateRef.current,
setState: (key, value) => {
if (Object.is(stateRef.current[key], value)) return;
if (key === "open" && typeof value === "boolean") {
stateRef.current.open = value;
propsRef.current.onOpenChange?.(value);
} else if (key === "selectedText" && typeof value === "string") {
stateRef.current.selectedText = value;
propsRef.current.onSelectionChange?.(value);
} else {
stateRef.current[key] = value;
}
if (!isBatching) {
store.notify();
}
},
notify: () => {
for (const cb of listenersRef.current) {
cb();
}
},
batch: (fn: () => void) => {
if (isBatching) {
fn();
return;
}
isBatching = true;
try {
fn();
} finally {
isBatching = false;
store.notify();
}
},
};
}, [listenersRef, stateRef, propsRef]);
useIsomorphicLayoutEffect(() => {
if (openProp !== undefined) {
store.setState("open", openProp);
}
}, [openProp]);
const open = useStore((state) => state.open, store);
const selectionRect = useStore((state) => state.selectionRect, store);
const rafRef = React.useRef<number | null>(null);
const mounted = React.useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
const virtualElement = React.useMemo(() => {
if (!selectionRect) return null;
return {
getBoundingClientRect: () => ({
x: selectionRect.left,
y: selectionRect.top,
width: selectionRect.width,
height: selectionRect.height,
top: selectionRect.top,
left: selectionRect.left,
right: selectionRect.left + selectionRect.width,
bottom: selectionRect.top + selectionRect.height,
}),
};
}, [selectionRect]);
const transformOrigin = React.useMemo<Middleware>(
() => ({
name: "transformOrigin",
fn(data) {
const { placement, rects } = data;
const [placedSide, placedAlign] =
getSideAndAlignFromPlacement(placement);
const noArrowAlign = { start: "0%", center: "50%", end: "100%" }[
placedAlign
];
let x = "";
let y = "";
if (placedSide === "bottom") {
x = noArrowAlign;
y = "0px";
} else if (placedSide === "top") {
x = noArrowAlign;
y = `${rects.floating.height}px`;
} else if (placedSide === "right") {
x = "0px";
y = noArrowAlign;
} else if (placedSide === "left") {
x = `${rects.floating.width}px`;
y = noArrowAlign;
}
return { data: { x, y } };
},
}),
[],
);
const desiredPlacement = React.useMemo(
() => (side + (align !== "center" ? `-${align}` : "")) as Placement,
[side, align],
);
const collisionPadding = React.useMemo(
() =>
typeof collisionPaddingProp === "number"
? collisionPaddingProp
: { top: 0, right: 0, bottom: 0, left: 0, ...collisionPaddingProp },
[collisionPaddingProp],
);
const boundary = React.useMemo(
() =>
Array.isArray(collisionBoundary)
? collisionBoundary
: [collisionBoundary],
[collisionBoundary],
);
const hasExplicitBoundaries = boundary.length > 0;
const detectOverflowOptions = React.useMemo(
() => ({
padding: collisionPadding,
boundary: boundary.filter(isNotNull),
altBoundary: hasExplicitBoundaries,
}),
[collisionPadding, boundary, hasExplicitBoundaries],
);
const sizeMiddleware = React.useMemo(
() =>
size({
...detectOverflowOptions,
apply: ({ elements, rects, availableWidth, availableHeight }) => {
const { width: anchorWidth, height: anchorHeight } = rects.reference;
const contentStyle = elements.floating.style;
contentStyle.setProperty(
"--selection-toolbar-available-width",
`${availableWidth}px`,
);
contentStyle.setProperty(
"--selection-toolbar-available-height",
`${availableHeight}px`,
);
contentStyle.setProperty(
"--selection-toolbar-anchor-width",
`${anchorWidth}px`,
);
contentStyle.setProperty(
"--selection-toolbar-anchor-height",
`${anchorHeight}px`,
);
},
}),
[detectOverflowOptions],
);
const middleware = React.useMemo<Array<Middleware | false | undefined>>(
() => [
offset({ mainAxis: sideOffset, alignmentAxis: alignOffset }),
avoidCollisions &&
shift({
mainAxis: true,
crossAxis: false,
limiter: sticky === "partial" ? limitShift() : undefined,
...detectOverflowOptions,
}),
avoidCollisions && flip({ ...detectOverflowOptions }),
sizeMiddleware,
transformOrigin,
hideWhenDetached &&
hide({ strategy: "referenceHidden", ...detectOverflowOptions }),
],
[
sideOffset,
alignOffset,
avoidCollisions,
sticky,
detectOverflowOptions,
sizeMiddleware,
transformOrigin,
hideWhenDetached,
],
);
const { refs, floatingStyles, isPositioned, middlewareData } = useFloating({
open: open && !!virtualElement,
placement: desiredPlacement,
strategy: "fixed",
middleware,
whileElementsMounted: (reference, floating, update) => {
return autoUpdate(reference, floating, update, {
animationFrame: updatePositionStrategy === "always",
});
},
elements: {
reference: virtualElement,
},
});
const closeToolbar = React.useCallback(() => {
const state = store.getState();
if (state.open || state.selectedText || state.selectionRect) {
store.batch(() => {
store.setState("open", false);
store.setState("selectedText", "");
store.setState("selectionRect", null);
});
}
}, [store]);
const updateSelection = React.useCallback(() => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
closeToolbar();
return;
}
const text = selection.toString().trim();
if (!text) {
closeToolbar();
return;
}
if (containerProp !== undefined) {
const resolvedContainer = getContainer();
if (!resolvedContainer) return;
const range = selection.getRangeAt(0);
const commonAncestor = range.commonAncestorContainer;
const element =
commonAncestor.nodeType === Node.ELEMENT_NODE
? (commonAncestor as Element)
: commonAncestor.parentElement;
if (!element || !resolvedContainer.contains(element)) {
closeToolbar();
return;
}
}
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const state = store.getState();
const hasChanges =
state.selectedText !== text ||
!state.selectionRect ||
state.selectionRect.top !== rect.top ||
state.selectionRect.left !== rect.left ||
state.selectionRect.width !== rect.width ||
state.selectionRect.height !== rect.height ||
!state.open;
if (hasChanges) {
store.batch(() => {
store.setState("selectedText", text);
store.setState("selectionRect", {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
});
store.setState("open", true);
});
}
}, [containerProp, getContainer, store, closeToolbar]);
const scheduleUpdate = React.useCallback(() => {
if (rafRef.current !== null) return;
rafRef.current = requestAnimationFrame(() => {
if (store.getState().open) {
updateSelection();
}
rafRef.current = null;
});
}, [store, updateSelection]);
React.useEffect(() => {
const container = getContainer() ?? document;
function onMouseUp() {
requestAnimationFrame(() => {
updateSelection();
});
}
function onSelectionChange() {
const selection = window.getSelection();
if (!selection || !selection.toString().trim()) {
closeToolbar();
}
}
container.addEventListener("mouseup", onMouseUp);
document.addEventListener("selectionchange", onSelectionChange);
window.addEventListener("scroll", scheduleUpdate, { passive: true });
window.addEventListener("resize", scheduleUpdate, { passive: true });
return () => {
container.removeEventListener("mouseup", onMouseUp);
document.removeEventListener("selectionchange", onSelectionChange);
window.removeEventListener("scroll", scheduleUpdate);
window.removeEventListener("resize", scheduleUpdate);
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, [getContainer, updateSelection, closeToolbar, scheduleUpdate]);
const clearSelection = React.useCallback(() => {
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
}
closeToolbar();
}, [closeToolbar]);
React.useEffect(() => {
if (!open) return;
function onMouseDown(event: MouseEvent) {
const target = event.target as Node;
if (refs.floating.current && !refs.floating.current.contains(target)) {
clearSelection();
}
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
clearSelection();
}
}
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open, refs.floating, clearSelection]);
const portalContainer =
portalContainerProp ?? (mounted ? globalThis.document?.body : null);
if (!portalContainer || !open) return null;
const RootPrimitive = asChild ? Slot : "div";
return (
<StoreContext.Provider value={store}>
{ReactDOM.createPortal(
<div
ref={refs.setFloating}
style={{
...floatingStyles,
transform: isPositioned
? floatingStyles.transform
: "translate(0, -200%)",
minWidth: "max-content",
...(middlewareData.hide?.referenceHidden && {
visibility: "hidden",
pointerEvents: "none",
}),
}}
data-state={isPositioned ? "positioned" : "measuring"}
>
<RootPrimitive
role="toolbar"
aria-label="Text formatting toolbar"
data-slot="selection-toolbar"
data-state={open ? "open" : "closed"}
{...rootProps}
className={cn(
"flex items-center gap-1 rounded-lg border bg-card px-1.5 py-1.5 shadow-lg outline-none",
isPositioned &&
"fade-in-0 zoom-in-95 animate-in duration-200 [animation-timing-function:cubic-bezier(0.16,1,0.3,1)]",
"motion-reduce:animate-none motion-reduce:transition-none",
className,
)}
style={{
transformOrigin: middlewareData.transformOrigin
? `${middlewareData.transformOrigin.x} ${middlewareData.transformOrigin.y}`
: undefined,
animation: !isPositioned ? "none" : undefined,
...style,
}}
/>
</div>,
portalContainer,
)}
</StoreContext.Provider>
);
}
interface SelectionToolbarItemProps
extends Omit<React.ComponentProps<typeof Button>, "onSelect"> {
onSelect?: (text: string, event: Event) => void;
}
function SelectionToolbarItem(props: SelectionToolbarItemProps) {
const {
onSelect: onSelectProp,
onClick: onClickProp,
onPointerDown: onPointerDownProp,
onPointerUp: onPointerUpProp,
className,
ref,
...itemProps
} = props;
const store = useStoreContext(ITEM_NAME);
const propsRef = useAsRef({
onSelect: onSelectProp,
onClick: onClickProp,
onPointerDown: onPointerDownProp,
onPointerUp: onPointerUpProp,
});
const itemRef = React.useRef<ItemElement>(null);
const composedRef = useComposedRefs(ref, itemRef);
const pointerTypeRef =
React.useRef<React.PointerEvent["pointerType"]>("touch");
const onSelect = React.useCallback(() => {
const item = itemRef.current;
if (!item) return;
const text = store.getState().selectedText;
const selectEvent = new CustomEvent("selectiontoolbar.select", {
bubbles: true,
cancelable: true,
detail: { text },
});
item.addEventListener(
"selectiontoolbar.select",
(event) => propsRef.current.onSelect?.(text, event),
{
once: true,
},
);
item.dispatchEvent(selectEvent);
}, [propsRef, store]);
const onPointerDown = React.useCallback(
(event: React.PointerEvent<ItemElement>) => {
pointerTypeRef.current = event.pointerType;
propsRef.current.onPointerDown?.(event);
if (event.pointerType === "mouse") {
event.preventDefault();
}
},
[propsRef],
);
const onClick = React.useCallback(
(event: React.MouseEvent<ItemElement>) => {
propsRef.current.onClick?.(event);
if (event.defaultPrevented) return;
if (pointerTypeRef.current !== "mouse") {
onSelect();
}
},
[propsRef, onSelect],
);
const onPointerUp = React.useCallback(
(event: React.PointerEvent<ItemElement>) => {
propsRef.current.onPointerUp?.(event);
if (event.defaultPrevented) return;
if (pointerTypeRef.current === "mouse") {
onSelect();
}
},
[propsRef, onSelect],
);
return (
<Button
type="button"
data-slot="selection-toolbar-item"
variant="ghost"
size="icon"
{...itemProps}
className={cn("size-8", className)}
ref={composedRef}
onPointerDown={onPointerDown}
onClick={onClick}
onPointerUp={onPointerUp}
/>
);
}
function SelectionToolbarSeparator(props: DivProps) {
const { asChild, className, ...separatorProps } = props;
const SeparatorPrimitive = asChild ? Slot : "div";
return (
<SeparatorPrimitive
role="separator"
aria-orientation="vertical"
aria-hidden="true"
data-slot="selection-toolbar-separator"
{...separatorProps}
className={cn("mx-0.5 h-6 w-px bg-border", className)}
/>
);
}
export {
SelectionToolbar,
SelectionToolbarItem,
SelectionToolbarSeparator,
//
useStore as useSelectionToolbar,
//
type SelectionToolbarProps,
};Update the import paths to match your project setup.
Layout
Import the parts, and compose them together.
import {
SelectionToolbar,
SelectionToolbarItem,
SelectionToolbarSeparator,
} from "@/components/ui/selection-toolbar";
return (
<SelectionToolbar>
<SelectionToolbarItem />
<SelectionToolbarSeparator />
</SelectionToolbar>
)Examples
Selection Info
Track selection information with the onSelectionChange callback to display word count, character count, and other metrics.
"use client";
import { Bold, Copy, Italic } from "lucide-react";
import * as React from "react";
import {
SelectionToolbar,
SelectionToolbarItem,
SelectionToolbarSeparator,
} from "@/components/ui/selection-toolbar";
export function SelectionToolbarInfoDemo() {
const containerRef = React.useRef<HTMLDivElement>(null);
const [selectedText, setSelectedText] = React.useState("");
const [wordCount, setWordCount] = React.useState(0);
const [charCount, setCharCount] = React.useState(0);
const wrapSelection = (tagName: string) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const selectedText = range.toString();
if (!selectedText) return;
const wrapper = document.createElement(tagName);
try {
range.surroundContents(wrapper);
} catch {
wrapper.textContent = selectedText;
range.deleteContents();
range.insertNode(wrapper);
}
selection.removeAllRanges();
};
const onBold = () => {
wrapSelection("strong");
};
const onItalic = () => {
wrapSelection("em");
};
const onCopy = (text: string) => {
navigator.clipboard.writeText(text);
};
const onSelectionChange = (text: string) => {
setSelectedText(text);
const words = text.trim().split(/\s+/).filter(Boolean);
setWordCount(words.length);
setCharCount(text.length);
};
return (
<div className="flex min-h-[400px] w-full flex-col items-center justify-center gap-4">
<div
ref={containerRef}
contentEditable
suppressContentEditableWarning
className="w-full max-w-2xl space-y-4 rounded-lg border bg-card p-8 text-card-foreground outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<h2 className="font-semibold text-2xl">Selection Info Tracking</h2>
<p className="text-muted-foreground leading-relaxed">
Select any text to see the toolbar and track selection information
below. The component provides callbacks to monitor selected text and
implement custom behavior.
</p>
<p className="text-muted-foreground leading-relaxed">
Try selecting different portions of text to see real-time updates of
the word count and character count. This demonstrates how you can
track and respond to selection changes.
</p>
<SelectionToolbar
container={containerRef}
onSelectionChange={onSelectionChange}
>
<SelectionToolbarItem onSelect={onBold}>
<Bold />
</SelectionToolbarItem>
<SelectionToolbarItem onSelect={onItalic}>
<Italic />
</SelectionToolbarItem>
<SelectionToolbarSeparator />
<SelectionToolbarItem onSelect={onCopy}>
<Copy />
</SelectionToolbarItem>
</SelectionToolbar>
</div>
{selectedText && (
<div className="flex w-full max-w-2xl flex-col gap-2 rounded-lg border bg-muted/50 p-4">
<div className="font-medium text-sm">Selection Info</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Words: </span>
<span className="font-medium">{wordCount}</span>
</div>
<div>
<span className="text-muted-foreground">Characters: </span>
<span className="font-medium">{charCount}</span>
</div>
</div>
<div className="text-muted-foreground text-xs">
<span className="font-medium">Selected text: </span>"
{selectedText.length > 50
? `${selectedText.slice(0, 50)}...`
: selectedText}
"
</div>
</div>
)}
</div>
);
}API Reference
SelectionToolbar
The root component that manages the toolbar's visibility and positioning.
Prop
Type
| Data Attribute | Value |
|---|---|
[data-state] | "open" | "closed" |
| CSS Variable | Description | Default |
|---|---|---|
--selection-toolbar-available-width | The available width in the viewport for the toolbar to fit within, accounting for collision boundaries. | Dynamic (e.g., 1200px) |
--selection-toolbar-available-height | The available height in the viewport for the toolbar to fit within, accounting for collision boundaries. | Dynamic (e.g., 800px) |
--selection-toolbar-anchor-width | The width of the selected text (anchor element). | Dynamic (e.g., 150px) |
--selection-toolbar-anchor-height | The height of the selected text (anchor element). | Dynamic (e.g., 24px) |
SelectionToolbarItem
An actionable item within the toolbar, typically containing an icon.
Prop
Type
SelectionToolbarSeparator
A visual separator between toolbar items.
Prop
Type
Accessibility
Keyboard Interactions
| Key | Description |
|---|---|
| Escape | Closes the toolbar and clears the text selection. |