Selection Toolbar
A floating toolbar that appears on text selection with formatting and utility actions.
Preview
Aura treats typography as a fluid system. Select any of this copy to open a formatting toolbar above the highlight. Escape dismisses the toolbar and clears the selection.
import * as React from "react";
import {
CopyIcon,
FontBoldIcon,
FontItalicIcon,
Link2Icon,
StrikethroughIcon,
UnderlineIcon,
} from "@radix-ui/react-icons";
import {
SelectionToolbar,
SelectionToolbarItem,
SelectionToolbarSeparator,
} from "@/components/ui/SelectionToolbar";
export function SelectionToolbarDemo() {
return {
const [copied, setCopied] = React.useState("");
return (
<div className="w-full max-w-lg">
<p className="text-gray-12">
{sampleCopy}
</p>
{copied ? (
<p className="mt-1 text-sm text-gray-11">Copied: {copied}</p>
) : null}
<SelectionToolbar>
<SelectionToolbarItem aria-label="Bold">
<FontBoldIcon className="icon" />
</SelectionToolbarItem>
<SelectionToolbarItem aria-label="Italic">
<FontItalicIcon className="icon" />
</SelectionToolbarItem>
<SelectionToolbarItem aria-label="Underline">
<UnderlineIcon className="icon" />
</SelectionToolbarItem>
<SelectionToolbarSeparator />
<SelectionToolbarItem aria-label="Strikethrough">
<StrikethroughIcon className="icon" />
</SelectionToolbarItem>
<SelectionToolbarItem aria-label="Link">
<Link2Icon className="icon" />
</SelectionToolbarItem>
<SelectionToolbarSeparator />
<SelectionToolbarItem
aria-label="Copy"
onSelect={(text) => {
void navigator.clipboard.writeText(text);
setCopied(text);
}}
>
<CopyIcon className="icon" />
</SelectionToolbarItem>
</SelectionToolbar>
</div>
);
};Installation
Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces
pnpm dlx shadcn@latest add @aura/selection-toolbarManual
Install the following dependencies:
pnpm install @floating-ui/react-dom @radix-ui/react-slotCopy and paste the button component into your components/ui/Button.tsx file.
/**
* @description Displays a button or a component that looks like a button.
*/
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/class-names";
const buttonVariants = cva("button", {
variants: {
variant: {
default: "button-fill",
fill: "button-fill",
pill: "button-pill border border-gray-6 text-gray-11 bg-gray-2 hover:bg-gray-3",
link: "button-link",
menu: "button-menu",
},
size: {
default: "h-4",
xs: "h-2.5",
sm: "h-3",
md: "h-4",
lg: "h-5",
xl: "h-6",
icon: "w-3 h-3 p-0",
"icon-md": "w-4 h-4 p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
interface ButtonProps
extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
isDisabled?: boolean;
isLoading?: boolean;
isLoadingText?: string | React.ReactNode;
mode?: VariantProps<typeof buttonVariants>["variant"];
label?: string | React.ReactNode;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(props: ButtonProps, ref) => {
const {
className,
variant,
mode,
size,
asChild = false,
isDisabled,
isLoading,
isLoadingText,
children,
label,
...rest
} = props;
const Comp = asChild ? Slot : "button";
const disabled = isDisabled || isLoading || props.disabled;
const effectiveVariant = variant ?? mode;
return (
<Comp
data-slot="button"
className={cn(
buttonVariants({ variant: effectiveVariant, size, className }),
disabled && "opacity-50 cursor-not-allowed"
)}
ref={ref}
disabled={disabled}
{...rest}
>
{asChild ? (
children
) : (
<>
{isLoading && isLoadingText ? isLoadingText : <>{label}{children}</>}
</>
)}
</Comp>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
export type { ButtonProps };
export default Button;
Copy and paste the class names utility into your utils/class-names.ts file.
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy and paste the compose refs utility into your utils/compose-refs.tsx file.
/**
* @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 use as ref hook into your hooks/use-as-ref.ts file.
import * as React from "react";
import { useIsomorphicLayoutEffect } from "@/hooks/use-isomorphic-layout-effect";
function useAsRef<T>(props: T) {
const ref = React.useRef<T>(props);
useIsomorphicLayoutEffect(() => {
ref.current = props;
});
return ref;
}
export { useAsRef };
Copy and paste the use isomorphic layout effect hook into your hooks/use-isomorphic-layout-effect.ts file.
import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };
Copy and paste the use lazy ref hook into your hooks/use-lazy-ref.ts file.
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 SelectionToolbar component into your components/ui/SelectionToolbar.tsx file.
"use client";
/**
* @description A floating toolbar that appears on text selection with formatting and utility actions.
*/
import * as React from "react";
import * as ReactDOM from "react-dom";
import {
autoUpdate,
flip,
hide,
limitShift,
type Middleware,
offset,
type Placement,
shift,
size,
useFloating,
type VirtualElement,
} from "@floating-ui/react-dom";
import { Slot } from "@radix-ui/react-slot";
import { useComposedRefs } from "@/utils/compose-refs";
import { cn } from "@/utils/class-names";
import { useAsRef } from "@/hooks/use-as-ref";
import { useIsomorphicLayoutEffect } from "@/hooks/use-isomorphic-layout-effect";
import { useLazyRef } from "@/hooks/use-lazy-ref";
import { Button } from "@/components/ui/Button";
const ROOT_NAME = "SelectionToolbar";
const ITEM_NAME = "SelectionToolbarItem";
type Side = "top" | "right" | "bottom" | "left";
type Align = "start" | "center" | "end";
type Boundary = Element | null;
interface DivProps extends React.ComponentProps<"div"> {
asChild?: boolean;
}
type ItemElement = React.ComponentRef<typeof Button>;
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 = 13,
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;
const storeApi: Store = {
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) {
storeApi.notify();
}
},
notify: () => {
for (const cb of listenersRef.current) {
cb();
}
},
batch: (fn: () => void) => {
if (isBatching) {
fn();
return;
}
isBatching = true;
try {
fn();
} finally {
isBatching = false;
storeApi.notify();
}
},
};
return storeApi;
}, [listenersRef, stateRef, propsRef]);
useIsomorphicLayoutEffect(() => {
if (openProp !== undefined) {
store.setState("open", openProp);
}
}, [openProp, store]);
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<VirtualElement | null>(() => {
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<Middleware[]>(
() =>
[
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 }),
].filter(Boolean) as Middleware[],
[
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?.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";
const origin = middlewareData.transformOrigin as
| { x: string; y: string }
| undefined;
return (
<StoreContext.Provider value={store}>
{ReactDOM.createPortal(
<div
ref={refs.setFloating}
style={{
...floatingStyles,
transform: isPositioned
? floatingStyles.transform
: "translate(0, -200%)",
minWidth: "max-content",
zIndex: 50,
...(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-0.5 rounded-md border border-gray-6 bg-gray-1 px-0.5 py-0.5 outline-none animate-selection-toolbar-show",
className,
)}
style={{
transformOrigin: origin
? `${origin.x} ${origin.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="pill"
size="icon"
{...itemProps}
className={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("h-3 w-px bg-gray-6", className)}
/>
);
}
export {
SelectionToolbar,
SelectionToolbarItem,
type SelectionToolbarProps,
SelectionToolbarSeparator,
useStore as useSelectionToolbar,
};
Usage
SelectionInfo
export const SelectionInfo = () => {
const [selectedText, setSelectedText] = React.useState("");
const words = selectedText.trim() ? selectedText.split(/\s+/).length : 0;
const characters = selectedText.length;
return (
<div className="w-full max-w-lg">
<p className="text-gray-12">
Highlight a phrase to see word and character counts in the toolbar.
</p>
<p className="mt-1 text-sm text-gray-11">
{selectedText
? `${words} words · ${characters} characters`
: "No selection"}
</p>
<SelectionToolbar onSelectionChange={setSelectedText}>
<span className="px-1 text-xs tabular-nums text-gray-11">
{words}w · {characters}c
</span>
<SelectionToolbarSeparator />
<SelectionToolbarItem
aria-label="Copy"
onSelect={(text) => {
void navigator.clipboard.writeText(text);
}}
>
<CopyIcon className="icon" />
</SelectionToolbarItem>
</SelectionToolbar>
</div>
);
};ScopedContainer
export const ScopedContainer = () => {
const containerRef = React.useRef<HTMLDivElement>(null);
return (
<div className="flex w-full max-w-lg flex-col gap-1">
<div
ref={containerRef}
className="rounded-md border border-gray-6 bg-gray-2 p-1"
>
<p className="text-sm text-gray-12">
Selection inside this card opens the toolbar. Text outside it does
not.
</p>
</div>
<p className="text-sm text-gray-11">
This paragraph is outside the scoped container, so selecting it will not
show the toolbar.
</p>
<SelectionToolbar container={containerRef}>
<SelectionToolbarItem aria-label="Bold">
<FontBoldIcon className="icon" />
</SelectionToolbarItem>
<SelectionToolbarItem aria-label="Italic">
<FontItalicIcon className="icon" />
</SelectionToolbarItem>
<SelectionToolbarSeparator />
<SelectionToolbarItem
aria-label="Copy"
onSelect={(text) => {
void navigator.clipboard.writeText(text);
}}
>
<CopyIcon className="icon" />
</SelectionToolbarItem>
</SelectionToolbar>
</div>
);
};