Selection actions
NewHighlight a passage and a small bar floats up under it: describe an edit, or ask to explain, improve, shorten, change the tone or fix the grammar.
selection-actions/selection-actions.tsxPistachio holds the top slot all weekend. Churn it first thing Saturday so the batch has time to firm up before the afternoon rush.
Select words with the pointer, or press Enter to select a sentence and Shift with the arrow keys to change where it ends. Then Tab to the actions.
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- Editing prose in place with an agent: select a passage, ask for a change, keep or drop the answer.
- A few fixed edits (improve, shorten, tone, grammar) plus one line to describe any other.
- Rewrites the reader should see in context before they land, with Keep, Discard and another try.
- Asking what a passage means without leaving the text: Explain answers in a note under the bar.
When not to use
- Formatting a selection (bold, links, lists). Use a format toolbar; this bar asks a model, it doesn’t style.
- Whole-document requests, or a conversation about the text. Use a chat panel beside it.
- Read-only text where nothing can change: offer copy or quote actions instead.
- Touch-first pages where the system selection menu already covers the job: the bar sits under the selection, where handles and the native menu compete.
- Long answers. The bar and its note are sized for a sentence or two, not a page.
You can add this component with the shadcn CLI or by hand:
Into components/selection-actions of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/selection-actions.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { SelectionActions, type RewriteRequest } from './selection-actions';async function rewrite({ action, passage, instruction }: RewriteRequest) { const response = await fetch('/api/rewrite', { method: 'POST', body: JSON.stringify({ action, passage, instruction }), }); return response.text();}export function Draft({ text, onSave }: { text: string; onSave: (text: string) => void }) { return ( <SelectionActions text={text} defaultSelection={null} rewrite={rewrite} onChange={onSave} autoplay={false} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
Select words in the paragraph and a pill-shaped bar appears under them. Type what to change, or pick an action; the chevron opens three more. The rewrite streams into the passage behind a caret, its newest letters still blurred, and the bar morphs to offer Keep, Discard or another try. Explain answers in a note under the bar and leaves the text as it was.
- The paragraph is ordinary selectable text. When a drag or a double click ends inside it, the selection is measured as character offsets (a Range from the paragraph’s start to the selection’s), trimmed of spaces, and drawn as a <mark> in its place, so it holds while the focus moves into the bar. While the reader drags, the browser’s own highlight wears the mark’s colour; a click in the text clears the mark.
- The bar stands centred under the mark’s bounding box, 8 px below it and never closer than 8 px to the stage’s edges. Whenever the mark moves (a new selection, or a rewrite growing and shrinking it) the bar glides there on 320 ms of a steep ease-in-out; it pops in from 95 % in 220 ms when a selection appears and fades out in 180 ms when it goes.
- The idle bar is three folding parts, each a max-width, opacity and transform transition of 400 ms on a soft ease-out. The chevron folds the field away (it slides 8 px left as it fades), opens Shorten, Tone and Grammar and turns half round. Typing locks the bar’s width, so the field takes it over while the actions fold and a send button grows in from 88 %.
- Between its states (actions, working, review) the bar swaps its content at once and eases its width from the old value to the new with the Web Animations API (320 ms), the content centred and clipped, so it seems to morph rather than jump.
- A request shows a spinner (a 12 px ring turning every 700 ms) and a shimmering “Improving…”, a text-clipped gradient sweeping every 1.8 s, for at least 700 ms. Then the answer streams into the mark two characters at a time, about 220 a second: the last six are blurred by 1.6 px and fade out to the right under a mask, led by a 2 px caret.
- Keep writes the rewrite into the text, keeps it selected and calls onChange; Discard puts the passage back; the round arrow asks again with the next attempt number. rewrite and explain may return a string or a promise, so a real model plugs straight in; without them a stand-in answers, with written rewrites for the sample and small rule-based ones for anything else.
- While on screen and untouched, a demo runs through it by itself: Improve then Discard, Shorten from the chevron and another try, Explain, a typed “Make it punchier” kept, then a fresh selection dragged word by word across the sentence. Its edits never reach onChange and are not announced; any key or press hands the component to the reader.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme or the colorScheme prop. The bar’s shadow is one list with each layer coloured for one scheme and clear in the other: a ring and long soft layers by day, a lighter ring and one dark drop at night.
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | a two-sentence sample | The paragraph the reader selects in. |
defaultSelection | [number, number] | null | the last sentence | What starts selected, as [start, end) character offsets into text; null starts with nothing selected and the bar hidden. |
defaultState | SelectionActionsState | — | Opens held still, with no demo: phase ('actions', 'working', 'streaming' or 'review'), action (the request, 'improve' by default), more (the extra actions open), draft (words in the field), progress (0 to 1 streamed) and result (the answer). |
rewrite | (request: RewriteRequest) => string | Promise<string> | a stand-in | Rewrites the selected passage: { action, passage, text, instruction?, attempt }. action is improve, shorten, tone, grammar, or custom for a typed instruction; attempt counts the retries. |
explain | (request: ExplainRequest) => string | Promise<string> | a stand-in | Explains the selected passage: { passage, text, attempt }. The answer streams into a note under the bar. |
onChange | (text: string) => void | — | Called with the whole new text when the reader keeps a rewrite. |
placeholder | string | 'Describe edits' | The field’s placeholder, and its accessible name. |
autoplay | boolean | true | While on screen and untouched, a demo runs through the actions, a typed edit and a fresh selection by itself. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode,} from 'react';import { CheckIcon, ChevronIcon, CrossIcon, ExplainIcon, GrammarIcon, ImproveIcon, RetryIcon, SendIcon,/* Selection actions (selection-actions.tsx): a paragraph on a soft grey stage, a passage marked in it, and the floating bar under the mark; at night a graphite stage and a bar a step lighter. Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The bar's shadow is one list for both schemes, each layer coloured for one of them and clear in the other. Motion: the bar's anchor glides by transform; inside the bar, parts fold and open by max-width, opacity and transform; the bar's own width morphs by a Web Animation (in the TSX). */.root { /* The stage, the bar, and the hover fills. */ --canvas: oklch(96.1% 0.002 247.84); --surface: oklch(100% 0 0); --hover: oklch(97% 0.002 247.839); --hover-2: oklch(93.3% 0.003 247.86); /* Words: strong, secondary and quiet. */ --ink: oklch(24.7% 0.006 258.361); --ink-2: oklch(50.6% 0.01 264.477); --ink-3: oklch(69.5% 0.009 264.505); /* Hairlines: the stage's edge and the soft divider, and the stronger divider. */ --line: oklch(94.6% 0.003 264.542); --line-strong: oklch(91.2% 0.005 258.326);// Copyright (c) 2026 Bruno Delorence. MIT License./* * The stand-in for a model: what the component answers when no rewrite or explain function is * given. The sample passage has written answers, a couple per action so another try reads * differently; any other passage the reader selects gets a small rule-based edit (tidied, * trimmed of filler, warmed up), so every button always does something sensible. */export type RewriteAction = 'improve' | 'shorten' | 'tone' | 'grammar' | 'custom';export type RewriteRequest = { /** Which button asked, or 'custom' for a typed instruction. */ action: RewriteAction; /** The selected words. */ passage: string; /** The whole text, for context. */ text: string; /** What the reader typed, for 'custom'. */ instruction?: string; /** 0 the first time, then 1, 2… for each retry of the same request. */ attempt: number;};// Copyright (c) 2026 Bruno Delorence. MIT License./* * The bar's glyphs, drawn on a 24-unit grid and shown at 14 px (the send arrow at 16 px). Each is * a plain stroke in currentColor, so the buttons colour them; all are decorative (aria-hidden), * since every button carries its own words. */type Props = { size?: number; stroke?: number };function Glyph({ size = 14, stroke = 1.8, children }: Props & { children: React.ReactNode }) { return ( <svg width={size} height={size} style={{ width: size, height: size }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false"'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- The paragraph can take the focus: Enter or Space selects the next sentence, Shift with the left and right arrows moves the selection’s end by a word, and Escape clears it. A hidden hint, tied to the paragraph by aria-describedby, says so. The selection is a <mark>, marked aria-busy while a rewrite streams into it.
- The bar is a toolbar named “Edit the selection”: Tab reaches the field and every button, and the left and right arrows walk the buttons. The chevron is a button with aria-expanded whose name says what it does (“Show more actions”, “Show fewer actions”); the send and retry buttons are named, every icon is aria-hidden.
- Parts of the bar that are folded away are inert, so neither Tab nor a screen reader lands on them. When the bar changes state with the focus in it, the focus moves to the new state’s first control (Keep after a rewrite), and while it works the bar itself holds the focus; when the bar closes, the focus goes back to the paragraph.
- Escape in the bar clears the field, discards a rewrite, or closes the bar. A polite live region says what happens: the passage selected, “Improving…”, the rewrite with “Keep it or discard it”, the explanation, kept or discarded.
- Focus rings are visible on the paragraph and on every button. Under reduced motion nothing glides, folds, pops or shimmers, the spinner holds still, the answer appears whole after a short wait, and the demo never plays.