Installation
npx shadcn@latest add @pixeldosa/selection-actionsRequires `cn` at `@/lib/utils` and the `diff-accept` primitive at `@/components/ui/diff-accept` (installed automatically as a registry dependency), used to render the 'replacement' result kind. Renders via a React portal to document.body with `position: fixed`, so it is not clipped by an `overflow: hidden` ancestor. Keyboard-only users generally cannot create an arbitrary text selection in static, non-editable content — a platform-level limitation this component cannot fix; the guarantee it makes is that once any selection exists, the toolbar itself is fully keyboard-operable.
Usage
"use client";
import * as React from "react";
import {
SelectionActions,
type SelectionAction,
type SelectionActionResult,
} from "@/components/ui/selection-actions";
const actions: SelectionAction[] = [
{ id: "explain", label: "Explain", icon: <ExplainIcon /> },
{ id: "rewrite", label: "Rewrite", icon: <RewriteIcon /> },
];
async function handleAction(
actionId: string,
selectedText: string,
signal: AbortSignal
): Promise<SelectionActionResult> {
const response = await callYourModel(actionId, selectedText, signal);
return response; // shaped as one of the SelectionActionResult kinds below
}
export function Article({ text }: { text: string }) {
const containerRef = React.useRef<HTMLDivElement>(null);
const [content, setContent] = React.useState(text);
return (
<>
<div ref={containerRef}>{content}</div>
<SelectionActions
containerRef={containerRef}
actions={actions}
onAction={handleAction}
onReplace={(merged, original) => setContent((c) => c.replace(original, merged))}
/>
</>
);
}
Only text selected inside containerRef triggers the toolbar — selections elsewhere
on the page are ignored, so multiple independent text regions don't fight over which
selection is active.
Result shapes
onAction must resolve to one of four shapes, each rendered differently:
type SelectionActionResult =
| { kind: "replacement"; value: string } // rendered via Diff Accept
| { kind: "text"; value: string } // prose, e.g. Explain/Translate
| { kind: "classification"; label: string; confidence?: "low" | "medium" | "high" }
| { kind: "list"; items: string[] }; // e.g. Extract
replacement is the only kind that can call onReplace — it renders through Diff Accept internally, so the user reviews and explicitly accepts before anything
changes. The other three kinds only display information; they never touch the
consumer's content.
Features
- Scoped to one container. Only selections inside
containerRefraise the toolbar, so independent text regions never fight over which selection is active. - Four result shapes, one component —
replacement,text,classificationandlisteach render appropriately, so Explain, Rewrite, Translate, Classify and Extract all share a single surface. - Only
replacementcan change your content, and it does so throughDiff Acceptrather than a second accept/reject implementation — composing an existing trust primitive instead of reinventing it (DESIGN.md §3.6). - The other three kinds are read-only by construction: they display and never call
onReplace. - Aborts on demand —
onActionreceives anAbortSignal, so a superseded or dismissed action cancels the model call rather than leaking it. - Honest about a platform limit it can't fix — see Accessibility.
Examples
A read-only action set, where nothing the model returns can modify the document — useful for reference material that must stay verbatim:
const actions: SelectionAction[] = [
{ id: "explain", label: "Explain", icon: <ExplainIcon /> },
{ id: "translate", label: "Translate", icon: <TranslateIcon /> },
];
<SelectionActions containerRef={containerRef} actions={actions} onAction={handleAction} />
Omitting onReplace entirely is the enforcement: with no handler, an accepted
replacement has nowhere to land.
Returning a classification rather than prose — the confidence tier flows straight into
Confidence Meter's presentation:
async function handleAction(actionId, selectedText, signal) {
if (actionId === "classify") {
const { label, tier } = await classify(selectedText, signal);
return { kind: "classification", label, confidence: tier };
}
return { kind: "text", value: await explain(selectedText, signal) };
}
Extraction, rendered as a list:
return { kind: "list", items: ["hello@acme.com", "+1 555 0123"] };
Making the toolbar less eager on long selections by waiting longer after the selection settles:
<SelectionActions
containerRef={containerRef}
actions={actions}
onAction={handleAction}
settleMs={400}
/>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
containerRef | React.RefObject<HTMLElement | null> | — | Required. Scopes selection detection to this element. |
actions | SelectionAction[] | — | Required. { id, label, icon } per action. |
onAction | (actionId, selectedText, signal) => Promise<SelectionActionResult> | — | Required. |
onReplace | (mergedValue: string, originalSelectedText: string) => void | — | Fires only for an accepted replacement result. |
settleMs | number | 200 | Debounce after the selection stops changing before the toolbar appears. |
Accessibility
- The toolbar is a real
role="toolbar"of<button>elements in natural tab order. - Result state changes are announced; confidence is always shown as text, never colour alone.
- A real limit, not glossed over: keyboard-only users generally cannot create an arbitrary text selection in static, non-editable content at all — this is a platform-level gap, not something a floating toolbar can fix. What this component does guarantee: once any selection exists (mouse, platform accessibility tooling, or keyboard inside an editable context where keyboard selection works), the toolbar itself is fully keyboard-operable.
- Escape dismisses the toolbar or result from anywhere within it; clicking outside does the same.
- Under
prefers-reduced-motion, the toolbar's entrance is instant rather than animated, with no loss of legibility.
When not to use
A whole-field or whole-document action, with no selection involved, is Smart Field
(one field) or Diff Accept directly (a rewrite already proposed elsewhere) — not
this component.
why it's built this way
Engineering Notes
Composes Diff Accept for the 'replacement' result kind (Rewrite) rather than reimplementing accept/reject a second time — Rewrite is exactly the 'AI proposes replacement text, human reviews' problem Diff Accept already solves, and the two components sharing one review UI keeps the whole AI tier consistent instead of each component inventing its own version of the same interaction. Selection detection listens to document-level `selectionchange`, filtered to whether the selection's anchor node falls inside `containerRef`, debounced (default 200ms) so the toolbar appears once the selection has settled rather than jittering while the user is still dragging or extending with the keyboard — one mechanism covers both input methods, since `selectionchange` fires for either. The toolbar's root uses `onMouseDown` with `preventDefault` — the standard rich-text-editor-toolbar trick — because without it, focusing a toolbar button would collapse the browser's live selection before the click handler ever runs, since focusing an element outside the current selection normally clears it. `onReplace` deliberately takes the original selected text as a second argument rather than assuming a plain string content model on the component's own side: this component cannot know whether the consumer's content is a plain string, a contentEditable region, or a structured rich-text document, so it hands back enough context (the exact original substring) for a plain-string consumer to splice correctly via `content.replace(original, merged)`, while a richer content model would use its own document API and ignore the second argument.
how it moves, and when it doesn't
Motion Notes
The toolbar mounts in a closed visual state and flips to open on the next animation frame (a `visible` state lagging one rAF behind the selection existing), the same two-phase-mount pattern `Command Menu` uses, so the fade+scale-in (`--pd-duration-fast`/`--pd-ease-standard`) actually has a 'from' value to animate away from rather than popping in already-visible. No motion plays while the selection itself is still changing — the toolbar simply doesn't exist yet during that phase (enforced by the debounce), which is the correct behaviour research on floating selection toolbars confirms (centre after the selection settles, not during), achieved structurally rather than via a suppressed-animation flag. Result content (prose, a classification label, a list, or the composed Diff Accept panel) swaps in via a plain state change, never a per-character reveal, consistent with every other AI-tier component's 'the value already exists in full' reasoning. Under `prefers-reduced-motion`, the fade/scale transition drops via `motion-reduce:transition-none` (scale pinned via `motion-reduce:scale-100`), leaving the toolbar's appearance instant but still fully legible.