PixelDosa UI

Selection Actions

A floating, selection-triggered toolbar for AI actions — explain, rewrite (reviewed via Diff Accept), translate, classify, extract.

The quick brown fox jumps over the lazy dog. Selecting any part of this paragraph reveals a floating toolbar with AI actions.

Select a few words above to reveal the toolbar. Try "Rewrite" to see the result routed through Diff Accept for review.

Installation

bash
npx shadcn@latest add @pixeldosa/selection-actions

Requires `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 containerRef raise the toolbar, so independent text regions never fight over which selection is active.
  • Four result shapes, one component — replacement, text, classification and list each render appropriately, so Explain, Rewrite, Translate, Classify and Extract all share a single surface.
  • Only replacement can change your content, and it does so through Diff Accept rather 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 — onAction receives an AbortSignal, 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

PropTypeDefaultDescription
containerRefReact.RefObject<HTMLElement | null>—Required. Scopes selection detection to this element.
actionsSelectionAction[]—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.
settleMsnumber200Debounce 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.