Skip to content

Selection actions

A floating toolbar on selected text: rewrite, shorten, translate or ask.

Category
AI
Adoption
Not used yet
import { SelectionActions } from "@oration/canon/components/ai/selection-actions";
packages/canon/src/components/ai/selection-actions.tsx
import { SelectionActions } from "@oration/canon/components/ai/selection-actions";import { Button } from "@oration/canon/components/button";import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import { TextSelectIcon } from "lucide-react";import * as React from "react";export function Hero() {    const id = React.useId();    const ref = React.useRef<HTMLTextAreaElement>(null);    const sentence =        "The $760.00 difference is the 2% early-payment discount in your terms, because we paid on day 8.";    const [value, setValue] = React.useState(        `Hi Aisha,\n\nThanks for calling about INV-20877. We paid it by ACH on Friday, September 25, for $18,240.00. ${sentence} The remittance advice is attached.\n\nBest,\nMaya Okafor`,    );    const canned: Record<string, string> = {        rewrite:            "We paid on day 8, so the 2% early-payment discount in your terms applied. That's the $760.00 difference.",        shorten: "The $760.00 is your 2% early-payment discount.",        expand: `${sentence} Your terms are 2/10 net 30, so any invoice we pay within 10 days is reduced by 2%.`,        tone: "Just so it's clear, the $760.00 is the 2% early-payment discount in your terms, since we paid on day 8.",        translate:            "La diferencia de $760.00 es el descuento del 2% por pronto pago de sus condiciones, porque pagamos el día 8.",        ask: "The $760.00 difference is the 2% early-payment discount (2/10 net 30), applied because we paid on day 8.",    };    return (        <div className="flex w-full max-w-xl flex-col gap-3 text-left">            <SelectionActions                languages={["Spanish", "French"]}                onAction={(actionId, text, options) => {                    if (text.trim() === sentence)                        return canned[actionId] ?? text;                    if (actionId === "shorten") {                        const words = text.trim().split(/\s+/);                        return `${words                            .slice(                                0,                                Math.max(3, Math.ceil(words.length * 0.6)),                            )                            .join(" ")                            .replace(/[,.;:]$/, "")}.`;                    }                    if (actionId === "expand") {                        return `${text.trim()} Reply to this email if anything still looks off.`;                    }                    if (actionId === "translate")                        return `[${options.language}] ${text}`;                    return text.trim().replace(/^./, (c) => c.toUpperCase());                }}            >                <div className="flex flex-col gap-1.5">                    <Label htmlFor={id}>Reply to Halcyon</Label>                    <Textarea                        ref={ref}                        id={id}                        rows={9}                        value={value}                        onChange={(event) => setValue(event.target.value)}                        className="text-[13px] leading-relaxed"                    />                </div>            </SelectionActions>            <Button                type="button"                variant="outline"                size="sm"                className="self-start"                onClick={() => {                    const field = ref.current;                    const start = value.indexOf(sentence);                    if (!field || start === -1) return;                    field.focus();                    field.setSelectionRange(start, start + sentence.length);                }}            >                <TextSelectIcon data-icon="inline-start" aria-hidden="true" />                Select the discount sentence            </Button>        </div>    );}

Usage#

Selection actions is a floating AI toolbar that appears over selected text inside whatever it wraps: a textarea, an input, a contenteditable or plain read-only text. It offers Rewrite, Shorten, Expand, Change tone, Translate and Ask, previews the result as a word diff, and only changes the text when the person accepts. In editable fields the replacement goes through the browser's own editing, so native undo still works. It's experimental and not used in the app yet; the common mistake would be wrapping a whole page in it, so every selected invoice number pops an AI toolbar.

When to use

  • Around long-form text people write for suppliers: a remittance email draft, a ticket reply, a campaign message.
  • Around read-only generated text that people may want to tighten before copying, such as a call summary, with onApply to store the change.
  • When the edit is local to a passage. Rewriting the whole field from a description is a different job.

When not to use

  • To generate or rewrite a whole field from a short instruction. Use AI generate button
  • For editing an agent's prompt, where the editor has its own inline AI. Use Prompt editor
  • For a conversation about the text. Send it to Copilot instead. Use Thread
  • Around tables, IDs, amounts or form fields with short values.
  • For a toolbar of formatting controls on a selection. Use Toolbar

Preview, then accept

Nothing changes until the person accepts. Rewrite, Shorten, tone changes and custom edits show a word diff against the selection; Expand and Translate show the new text.

The One Filled Button Rule

Inside the floating layer the only filled buttons are Accept (or Copy) on a result and Send instruction in Ask. The toolbar items are quiet text buttons.

The Tint Well Rule

The result sits in a 70% Well Gray well inside the popover surface.

Anatomy#

Shorten
The $760.00 removed: difference is removed: theadded: your 2% early-payment removed: discount in your terms.added: discount.
  1. Floating surface. Popover White, 12px corners, 4px padding and the overlay shadow, fixed 8px above the selection (below it when there's no room) and kept 8px inside the viewport.
  2. Actions. 28px text buttons with a 14px icon in Slate Meta: Rewrite, Shorten and Expand by default. A fluid Well Gray highlight follows the pointer and the arrow keys.
  3. Tone. A menu of tones. Hidden when tones is empty.
  4. Translate. A menu of languages. Hidden when languages is empty.
  5. Ask. Opens a one-line instruction field, Tell AI how to change this…, with a filled send button. Hidden with allowAsk={false}.
  6. Result. The action's name, then the diff or new text in a tint well, up to 192px tall before it scrolls.
  7. Try again. A ghost icon button that reruns the same request.
  8. Discard and Accept. Ghost Discard and a filled Accept. Accept reads Copy when the text can't be replaced.

Examples#

Read-only text with onApply

Select part of the summary. Accept calls onApply with the new text and the captured selection, and the page stores the change.

Call summary

Aisha Bello from Halcyon called about INV-20877, which was paid $760.00 short. Nora explained that the difference is the 2% early-payment discount in Halcyon's terms, and resent the remittance advice with the discount itemized. Aisha said she would update their ledger and didn't need a callback.

import { SelectionActions } from "@oration/canon/components/ai/selection-actions";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function ReadOnlyText() {    const [summary, setSummary] = React.useState(        "Aisha Bello from Halcyon called about INV-20877, which was paid $760.00 short. Nora explained that the difference is the 2% early-payment discount in Halcyon's terms, and resent the remittance advice with the discount itemized. Aisha said she would update their ledger and didn't need a callback.",    );    return (        <div className="w-full max-w-xl rounded-xl bg-card p-4 text-left shadow-border">            <h3 className="mb-1.5 text-sm font-semibold">Call summary</h3>            <SelectionActions                tones={[]}                languages={[]}                onAction={(actionId, text) => {                    const words = text.trim().split(/\s+/);                    if (actionId === "shorten") {                        return `${words                            .slice(                                0,                                Math.max(3, Math.ceil(words.length * 0.55)),                            )                            .join(" ")                            .replace(/[,.;:]$/, "")}.`;                    }                    if (actionId === "expand") {                        return `${text.trim()} The call lasted 2 minutes 14 seconds and ended without a transfer.`;                    }                    return text                        .trim()                        .replace(                            "didn't need a callback",                            "no callback is needed",                        );                }}                onApply={(next, range) => {                    setSummary((current) => current.replace(range.text, next));                    toast.add({ type: "success", title: "Summary updated" });                }}            >                <p className="text-sm leading-relaxed text-pretty text-foreground">                    {summary}                </p>            </SelectionActions>        </div>    );}

Custom actions and options

Replace the default actions, hide Change tone with an empty tones, limit Translate to two languages and turn off Ask.

import { SelectionActions } from "@oration/canon/components/ai/selection-actions";import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import { BriefcaseIcon, SpellCheckIcon } from "lucide-react";import * as React from "react";export function CustomActions() {    const id = React.useId();    const [value, setValue] = React.useState(        "Hi Tomás, we dont have a current W-9 for Orchard Street so OS-4502 is on hold. Can you upload one before friday so it goes in the payment run.",    );    return (        <div className="w-full max-w-xl text-left">            <SelectionActions                actions={[                    {                        id: "fix",                        label: "Fix grammar",                        pendingLabel: "Fixing grammar…",                        icon: <SpellCheckIcon />,                        diff: true,                    },                    {                        id: "formal",                        label: "Make formal",                        pendingLabel: "Rewriting…",                        icon: <BriefcaseIcon />,                        diff: true,                    },                ]}                tones={[]}                languages={["Spanish", "Portuguese"]}                allowAsk={false}                onAction={(actionId, text, options) => {                    if (actionId === "fix") {                        return text                            .replace("dont", "don't")                            .replace("Street so", "Street, so")                            .replace("friday", "Friday")                            .replace(/run\.$/, "run?");                    }                    if (actionId === "formal") {                        return text                            .replace(/^Hi Tomás, /, "Dear Tomás, ")                            .replace("Can you", "Could you please");                    }                    return `[${options.language}] ${text}`;                }}            >                <div className="flex flex-col gap-1.5">                    <Label htmlFor={id}>Ticket reply</Label>                    <Textarea                        id={id}                        rows={4}                        value={value}                        onChange={(event) => setValue(event.target.value)}                        className="text-[13px] leading-relaxed"                    />                </div>            </SelectionActions>        </div>    );}

Async results, errors and containerRef

Return a Promise from onAction; a rejection shows the error with Try again. containerRef watches an element you already render instead of wrapping it.

Expand always fails here, to show the error state.

import { SelectionActions } from "@oration/canon/components/ai/selection-actions";import { Label } from "@oration/canon/components/label";import { Textarea } from "@oration/canon/components/textarea";import * as React from "react";export function AsyncAndErrors() {    const id = React.useId();    const scope = React.useRef<HTMLDivElement>(null);    const [value, setValue] = React.useState(        "Your payment for invoice INV-20931 is scheduled for Tuesday's ACH run on September 29. The remittance will be emailed the same day.",    );    return (        <div className="w-full max-w-xl text-left">            <div ref={scope} className="flex flex-col gap-1.5">                <Label htmlFor={id}>Message to Northwind Freight</Label>                <Textarea                    id={id}                    rows={3}                    value={value}                    onChange={(event) => setValue(event.target.value)}                    className="text-[13px] leading-relaxed"                />                <p className="text-xs text-muted-foreground">                    Expand always fails here, to show the error state.                </p>            </div>            <SelectionActions                containerRef={scope}                tones={[]}                languages={[]}                allowAsk={false}                onAction={(actionId, text) =>                    new Promise<string>((resolve, reject) => {                        window.setTimeout(() => {                            if (actionId === "expand")                                reject(new Error("Model timeout"));                            else if (actionId === "shorten")                                resolve(                                    "INV-20931 pays Tuesday, September 29, by ACH.",                                );                            else                                resolve(                                    text.replace(                                        "Your payment for invoice",                                        "Payment for",                                    ),                                );                        }, 1200);                    })                }            />        </div>    );}

States#

Pending
Rewriting…
Error
Result with a diff
Rewrite
removed: Weadded: It removed: paidadded: went removed: itadded: out by ACH on removed: Fridayadded: Friday, added: September 25, for $18,240.00.
import {  makeRequest,  SelectionError,  SelectionPending,  SelectionResult,} from "@oration/canon/components/ai/selection-panels";import { toast } from "@oration/canon/components/toast";export function StatesPreview() {    return (        <div className="grid w-full gap-6 text-left md:grid-cols-[auto_1fr]">            <div className="flex flex-col items-start gap-6">                <div className="flex flex-col gap-2">                    <span className="text-xs text-muted-foreground">                        Pending                    </span>                    <div className="w-fit rounded-xl bg-popover p-1 shadow-popover">                        <SelectionPending                            label="Rewriting…"                            onCancel={() =>                                toast.add({ title: "Edit cancelled" })                            }                        />                    </div>                </div>                <div className="flex flex-col gap-2">                    <span className="text-xs text-muted-foreground">Error</span>                    <div className="w-fit rounded-xl bg-popover p-1 shadow-popover">                        <SelectionError                            onRetry={() => toast.add({ title: "Trying again" })}                            onDiscard={() =>                                toast.add({ title: "Edit discarded" })                            }                        />                    </div>                </div>            </div>            <div className="flex flex-col gap-2">                <span className="text-xs text-muted-foreground">                    Result with a diff                </span>                <div className="w-fit rounded-xl bg-popover p-1 shadow-popover">                    <SelectionResult                        request={makeRequest({                            id: "rewrite",                            label: "Rewrite",                            diff: true,                        })}                        before="We paid it by ACH on Friday for $18,240.00."                        after="It went out by ACH on Friday, September 25, for $18,240.00."                        acceptLabel="Accept"                        onRetry={() => toast.add({ title: "Trying again" })}                        onDiscard={() => toast.add({ title: "Edit discarded" })}                        onAccept={() =>                            toast.add({ title: "Selection replaced" })                        }                    />                </div>            </div>        </div>    );}
States
StateTreatment
ClosedNo selection, or the selection is outside the container.
MenuText is selected: the toolbar springs in from 0.97 scale over 80ms. Screen readers hear AI actions available. Press Command J. once.
AskThe toolbar becomes the instruction field, focused.
PendingA pixel-grid loader and a shimmering label, such as Rewriting…, with Cancel.
ResultThe preview with Try again, Discard and Accept. Accept is focused.
ErrorA red alert icon, Couldn't finish that edit., Try again (focused) and Discard.
Item hover and focusThe fluid highlight sits under the item and its text turns ink; keyboard focus adds a 2px Focus Indigo ring at 50%.

Behavior#

  • It listens for selections inside children (wrapped in a div) or inside the element at containerRef. Selecting with the pointer, Shift and the arrow keys, or ⌘A all open it.
  • With text selected, ⌘J (Ctrl+J elsewhere) opens the toolbar and moves focus to its first item. Arrow Left and Right, Home and End move between items.
  • Escape closes the menu, steps back from Ask to the menu, or discards a pending or finished result and restores the selection.
  • onAction(actionId, selectedText, options) runs the request. Return a string or a Promise of one; a rejected Promise shows the error state. A synchronous result waits simulateMs (700ms) so the pending state is visible.
  • options carries tone for Change tone, language for Translate and prompt for Ask. Their action ids are tone, translate and ask.
  • Accept in a textarea or input replaces the selection through execCommand("insertText"), so ⌘Z undoes it, and raises Selection replaced with Undo. In a contenteditable it replaces the range. In read-only text it calls onApply(newText, selection); without onApply it copies the result and toasts Copied to clipboard.
  • Clicking outside closes it. It follows the selection when the page scrolls or resizes. A newer request cancels an older one still in flight.
  • The layer is portaled to the body and exits in 60ms.

Do and don't#

Do. Wrap the passage people write, such as the reply body, and point containerRef at it.
Don't. Wrap the whole page. Selecting an invoice number or an amount shouldn't open an AI toolbar.
Do. Return the edited passage only, so the diff shows exactly what changed.
Don't. Return the whole document or a chatty reply such as Sure, here's a shorter version. It replaces the selection as written.
Do. Pass onApply for read-only text you can update, so Accept means accept.
Don't. Leave read-only text without onApply when people expect the change to stick. Accept quietly becomes Copy.

Content#

  • Action labels are single verbs or short verb phrases: Rewrite, Shorten, Fix grammar, Make formal.
  • pendingLabel is the verb in progress with an ellipsis: Rewriting…, Fixing grammar…. The default is Working on it….
  • Tone and language options are single words in sentence case: Professional, Friendly, Spanish.
  • Results should read as the person's own text, in the same voice and person, with IDs and amounts unchanged.

Accessibility#

  • The toolbar is role="toolbar" named Edit selection with AI, with arrow-key movement between items.
  • Opening is announced once per selection through a polite live region: AI actions available. Press Command J. (Control J off Apple platforms).
  • Focus moves into the layer when it matters: the first item on ⌘J, the field in Ask, Accept on a result, Try again on an error, and Cancel while pending if focus was already inside.
  • The pending row is role="status", the error is role="alert", and the result is a group named Suggested edit.
  • Pointer presses inside the layer don't steal the selection, so the text stays highlighted while you choose.
  • Discard and Escape restore the original selection, so keyboard users land back where they were.
Keyboard interactions
KeysAction
⌘JWith text selected, opens the toolbar and focuses it. Ctrl+J off Apple platforms.
←→Moves between toolbar items.
HomeFirst item.
EndLast item.
EnterRuns the focused action, or sends the Ask instruction.
EscCloses, steps back from Ask, or discards and restores the selection.

Design tokens#

Design tokens
TokenUsed for
--popoverFloating surface
shadow-popoverOverlay lift
--mutedFluid highlight, result well at 70%
--muted-foregroundResting items and icons, result label
--primaryAccept and Send instruction
--destructiveError icon; removed words in the diff, struck through on a 10% tint
--successAdded words in the diff, on a 15% tint
--ringItem focus ring at 50%
spring.fastEnter at 0.97 scale, 80ms
exit.fastExit, 60ms
text-shimmerPending label

API reference#

SelectionActions

The floating toolbar and its scope. Also exported: the CapturedSelection, SelectionAction and SelectionActionOptions types.

Other props spread onto Nothing. Only the props below are read..

Props of SelectionActions
PropTypeDefaultDescription
onActionRequired(actionId: string, selectedText: string, options: { language?: string; tone?: string; prompt?: string }) => Promise<string> | stringNo defaultProduces the replacement text.
childrenReact.ReactNodeNo defaultContent to watch, wrapped in a div. Omit when using containerRef.
containerRefReact.RefObject<HTMLElement | null>No defaultWatch an existing element instead of wrapping children.
actions{ id: string; label: string; pendingLabel?: string; icon?: React.ReactNode; diff?: boolean }[]Rewrite, Shorten, Expanddiff previews the result as a word diff.
languagesstring[]Spanish, French, German, Portuguese, Japanese, HindiPass [] to hide Translate.
tonesstring[]Professional, Friendly, Direct, Confident, EmpatheticPass [] to hide Change tone.
allowAskbooleantrueShows Ask.
onApply(newText: string, range: CapturedSelection) => voidNo defaultCalled on Accept. Required to replace read-only text.
simulateMsnumber700How long a synchronous onAction appears to work.
classNamestringNo defaultMerged onto the wrapper div when using children.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

It isn't used anywhere in the app. Fields get AIGenerateButton and the prompt editor has its own inline AI.

The result's Try again is an icon button with an aria-label but no tooltip.

Only field replacements get an Undo toast. Contenteditable relies on native undo, and onApply gets none, so callers must offer their own.

Accept on read-only text without onApply silently becomes Copy.

Replacement uses document.execCommand("insertText"), which is deprecated, with setRangeText as the fallback.

The panels it renders (SelectionToolbar, SelectionResult and the rest in selection-panels) are exported but undocumented; this page renders them statically only to show the anatomy and states.

Not checked: how it competes with the native selection menu on iOS and Android.