AI Selection Menu
An Ask-AI row that floats over a text selection — action shortcuts, submenus, a free-form question field, one captured excerpt and a keyboard path in.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/ai-selection-menu.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AiSelectionMenu" component (lucide-react
for icons, no popover library): a floating "Ask AI" row that appears over any
text selection made inside the region it wraps, and reports the chosen action to
the consumer. It runs nothing itself.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
children. The root is a position:relative scope; `children` render inside an
inner content wrapper and the floating row is that wrapper's SIBLING, so
reading the selection can never pick up the word "Summarize" from the row
hovering over it.
- children: the text this instance watches.
- actions?: AiSelectionAction[] — default summarize / explain / translate ▸ /
rewrite ▸ / ask.
AiSelectionAction = { type: string; label: string; icon?: ReactNode;
options?: { value: string; label: string; description?: string }[];
ask?: boolean; askPlaceholder?: string; disabled?: boolean }
`options` non-empty turns the item into a submenu trigger; `ask` turns it into
a disclosure that reveals a one-line question field.
- onAction: (request, controls) => void — required.
request = { type, selectionText, option?, query?, truncated, chars }
controls = { close(): void }
- onOpenChange?: (open: boolean) => void.
- label?: string (default "Ask AI") — the brand chip and the toolbar's
accessible name.
- maxLength?: number (default 4000) / minLength?: number (default 3).
- placement?: "auto" | "top" | "bottom" (default "auto").
- closeOnAction?: boolean (default true) — false keeps the row up so it can show
progress; the consumer closes it later through controls.close().
- pending?: boolean — the row shows a spinner and "Working…", every item goes
aria-disabled (the question field aria-disabled + readOnly), and the card
carries aria-busy. Never the native `disabled` attribute: it would blur the
item the reader just pressed and drop focus to <body> mid-request. The
handlers re-check `pending` instead, and the roving tab stop stays where it is
while the request is in flight.
- includeEditable?: boolean (default false) — also offer the row for selections
inside a contenteditable in this scope. Selections inside input / textarea /
select are ALWAYS ignored: they belong to that field.
- disabled?: boolean — render no offer at all (a message that is still
streaming, a redacted passage). Honour it by not rendering, not by an effect
that clears state, so it takes effect in the same commit.
- Also export normalizeSelectionText(raw, maxLength) and
DEFAULT_AI_SELECTION_ACTIONS so a consumer can extend the default row.
Behavior — detection
- One document-level listener set per scope: `selectionchange` (coalesced into a
single requestAnimationFrame — it fires once per arrow key of a Shift+Arrow
selection and once per pointer move of a drag), plus capture-phase pointerdown
/ pointerup / pointercancel and window blur.
- Drag gate: while a pointer is down the selection is not a decision yet, so
evaluation is suspended and re-run on pointerup. A drag released outside the
window never delivers pointerup — window blur must reopen the gate or no
selection will ever produce an offer again.
- Resolve the scope with `range.commonAncestorContainer`: it is the deepest node
holding BOTH endpoints, so "inside the scope" also proves "both ends are
inside the scope". A selection that spills into the page around it is ignored
instead of quietly sending half of it.
- Multi-range selections (Firefox Ctrl+drag): use the LAST range and that
range's own text, because the row is positioned against that range only.
- Ignore ranges under minLength and ranges whose bounding box is 0×0 (a run of
collapsed whitespace has nothing to point at).
- Geometry is stored SCOPE-relative (x = box centre, top, bottom, scope width),
so scrolling the page or an ancestor container moves the row with the text for
free. Keep the viewport-space top of the box for the flip decision only, and
exclude it from the "did the anchor really change?" comparison, otherwise the
row hops from above the selection to below it while the page scrolls.
- Re-measure through a ResizeObserver on the scope: in a chat the answer above
can still be streaming, so the selected paragraph moves with no scroll and no
resize event.
Behavior — the excerpt
- Capture the text at OFFER time and never read it back off the DOM afterwards.
Focusing the question field collapses the page selection; a payload derived at
click time would be empty exactly when the reader asked for the most work.
- normalizeSelectionText does NOT collapse whitespace — indentation and line
breaks are meaning when the selection is code or a list, and this string is
going into a prompt, not into a chip label. Normalize CRLF, drop spaces before
a newline, trim the ends.
- Over maxLength: cut at the last word boundary in the tail unless that throws
away more than a fifth of the budget (then cut hard), set truncated, keep the
original length in `chars`, and show one muted line — "Sending the first N of
M selected characters". Never silently drop the tail: the reader must know
what the model is actually being given.
Behavior — the three locks
- Drag gate (above): no offer while the pointer is down.
- Latch: the moment focus or a pointer lands inside the row, detection is
suspended and the anchor is frozen. Without it, focusing the question field
collapses the selection and the row deletes itself mid-question.
- One-shot lock: acting on a range or dismissing it with Escape marks that
range's signature as settled, so the row cannot pop straight back up over a
selection the browser is still showing. The lock is released when the
selection collapses OR on the next pointerdown outside the row — a new gesture
is a new intention, and re-selecting the same sentence must offer it again.
Behavior — keyboard
- One Tab after an offer appears is captured document-wide and routed into the
row (remember document.activeElement first). A floating row is not reliably
next in the reading order of the selection, so relying on sequential focus
navigation strands keyboard users. Hijack it EXACTLY once per offer: capturing
every Tab that arrives from outside bounces focus straight back in the moment
the reader tabs out, which turns a floating row into a focus trap. Once focus
is inside, Tab behaves natively again and leaves.
- The row is role="toolbar" with a roving tabindex: exactly one item is tabbable,
ArrowLeft / ArrowRight wrap, Home / End jump, disabled items are skipped.
Moving the tab stop closes any open submenu or question field.
- ArrowDown (or Enter / Space, which a <button> fires natively) opens the item's
submenu, role="menu" with role="menuitem" rows and its own ArrowUp/Down/Home/
End; Escape or ArrowLeft returns to the trigger with the offer still standing
— closing a submenu is not the same decision as declining the menu.
- Escape on the row dismisses the offer, restores focus to where it came from,
and does NOT clear the highlight: the reader said "not this", not "unselect".
A document-level Escape handler covers the mouse case (nothing focused), and
must ignore events originating inside the row so the submenu keeps its own.
- The question field submits on Enter but only when
event.nativeEvent.isComposing is false, or an IME candidate window turns half
a Japanese word into a question.
- One sr-only role="status" node per offer announces that actions are available
and how to reach them; the wording is constant, so it is spoken once per offer
instead of on every keystroke of a Shift+Arrow selection.
Behavior — positioning
- Wrapper: absolute, left = clamped selection centre, -translate-x-1/2, and
-translate-y-full when placed above so the card grows upwards on screen while
it grows downwards in the DOM. The anchor cannot drift when a row is added.
- Clamp left in a layout effect once the row has a width, keeping EDGE_MARGIN
from both scope edges; if the row is wider than the scope, centre it instead.
A layout effect runs before paint, so the corrected position is the first one
the reader ever sees.
- Choose above vs below from the height the menu CAN reach (row + question row
if any action declares ask + truncation note), not the height it has right
now. Measuring the live card flips the side the moment the question field
opens and throws the whole row across the selection mid-interaction.
- The submenu opens downwards from the card, or upwards when the card sits above
the selection so it never covers the very text it is about to act on; align it
with its trigger via offsetLeft, then pull it back inside the card's width.
- Cancel mousedown on the row so the caret is not moved and the highlight
survives — with ONE exception: the question field must get the default, it
needs focus and a caret. Everything else already has the excerpt.
Behavior — firing
- Plain item: fire immediately. Submenu item: fire with `option`. Question field:
fire with the trimmed `query`, empty is a no-op and the send button reads
aria-disabled. Every path resets the submenu, the field and the draft query.
- Then close (closeOnAction) or stay up for the consumer's progress state. Key
the floating row on the anchor signature: a new range is a new offer, and
remounting resets the roving index, the open submenu and a half-typed question
in one line.
- Cleanup on unmount: cancel the frame, remove all four listeners plus window
blur, disconnect the observer.
Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground for the card and
the submenu, border for the hairlines and separators, hover:bg-accent /
hover:text-accent-foreground for items, text-muted-foreground for the brand
chip, descriptions and the truncation note, text-primary for the sparkle,
bg-primary / text-primary-foreground for the send button, ring for every
focus-visible ring. No hex, no rgb(), no oklch().
- The card is w-max with a max width, and both the brand row and the toolbar
wrap, so a narrow scope gets two short lines instead of a horizontally
overflowing bar.
- The pop-in keyframes ship in a React 19 hoisted <style href precedence> and
are motion-reduce:[animation:none]; the chevron rotation and colour
transitions are motion-reduce:transition-none. Nothing about reaching, opening
or firing an action depends on animation.
- aria-expanded on submenu and question triggers, aria-haspopup="menu" on
submenu triggers only (a disclosure field is not a menu), aria-busy on the
card while pending, aria-hidden on the sparkle, the separator and icon slots.
Customization levers
- The action set is the product: replace `actions` wholesale (Explain / Add
types / Find bugs for code, Translate ▸ languages for a reader, Rewrite ▸ tones
for a composer) or spread DEFAULT_AI_SELECTION_ACTIONS and append. `type` is
yours — it is the discriminant your handler switches on.
- Ask field: drop the ask action for a pure one-click row, or make it the only
action for a minimal "Ask AI" affordance; askPlaceholder carries the domain.
- Budget: maxLength decides what the model is given; lower it hard for expensive
models, raise it for whole-article summaries. minLength kills stray
double-click offers.
- placement="bottom" for scopes that live at the top of a scroll container;
keep "auto" everywhere else. GAP, EDGE_MARGIN and the reserved row heights are
the only geometry constants.
- Density: the item is h-7 / text-xs / gap-1.5 and the card is rounded-lg —
scale those three for a chunkier bar. Swap the brand chip for your own mark,
or delete it and let the actions speak.
- Progress: closeOnAction={false} + pending renders the busy row; call
controls.close() when the answer lands. With closeOnAction left true, wire the
result to a side panel, a rewrite diff or a toast — the menu deliberately owns
no result surface.
- includeEditable={true} inside an editor, and pair it with your own
formatting toolbar rather than replacing it: this row is about meaning, that
one is about markup.Concepts
- Capture at offer time — the excerpt is frozen into the offer the moment the row appears, because every way out of the row moves focus and the question field collapses the page selection outright; reading the DOM at click time would hand the model an empty string exactly when the reader asked for the most work.
- Drag gate — nothing is offered while a pointer is down: mid-drag the selection is a gesture in progress, and a row that chased the pointer across the paragraph would be unclickable;
pointerup,pointercanceland window blur are all treated as "the gesture ended". - Latch on interaction — once focus or a press lands inside the row, selection detection switches off and the anchor freezes, so typing a question cannot delete the very menu it is being typed into.
- One-shot lock — a range that was acted on or dismissed is remembered by signature so the row cannot pop straight back up over a highlight the browser is still showing; the lock lifts when the selection collapses or when the next press outside the row starts a new intention.
- Tab into the toolbar — a floating row is not reliably next in the reading order after a selection, so the first Tab of each offer (and only the first, or the row becomes a focus trap) is captured and routed into a roving-tabindex toolbar; the next Tab leaves natively, and Escape returns focus to where it came from without clearing the highlight.
- Reserved-height flip — above-or-below is decided from the tallest state the card can reach, not its current height, so opening the question field never throws the whole row across the selection mid-interaction.
- No result surface — the component reports
{type, selectionText, option, query}and stops there; the answer belongs to a panel, a diff or a toast you own, which is what keeps one row usable for summarizing, translating and rewriting alike.
Conversation Title
An auto-generated chat title — a sparkle provenance hint, click-to-edit rename with a length counter, and a regenerate button that shimmer-cycles candidate names before settling.
Rewrite Preview
A prose rewrite review panel — sentence-level tints, per-paragraph Accept / Keep verdicts, measured delta chips and a one-shot Apply that hands back the merged draft.