Quote Reply
Quote-and-reply for a transcript — a floating Quote action over a text selection, a dismissible quote chip above the composer, and a whole-message fallback.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/quote-reply.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "QuoteReply" component set (lucide-react
for icons, no other runtime dependency) that lets a reader quote a sentence out
of a chat message into the composer. Ship it as a provider + hook + parts:
QuoteReplyProvider, useQuoteReply, Quotable, QuoteChip, QuoteMessageButton.
Contract
- Quote = { text: string; messageId: string; author?: string;
origin: "selection" | "message"; truncated: boolean }. `text` is already
normalized; `messageId` is the caller's own id, echoed back untouched.
- QuoteReplyProvider(props): children; quote?: Quote | null (controlled — use
null for "nothing pending", undefined for uncontrolled); defaultQuote (null);
onQuoteChange(quote | null) on every change including clears; onQuote(quote)
only when a new quote is captured; maxLength (280); minLength (2);
actionLabel ("Quote"); floatingAction (true). Renders no DOM of its own.
- useQuoteReply() -> { quote, setQuote, clearQuote, quoteText({messageId, text,
author?, origin?}), quoteMessage(messageId): boolean, quoteSelection():
boolean, selection: {messageId, author?, text, truncated} | null,
selectionSupported: boolean }. Throws outside the provider with a message
that names the provider — a silent no-op here is hours of debugging.
- Quotable(props): messageId (required), author?, text? (authoritative plain
text for whole-message quoting; defaults to the rendered text), disabled?,
plus div attributes; forwardRef to the wrapper.
- QuoteChip(props): quote? (defaults to the provider's pending quote),
onDismiss? (defaults to clearQuote), onJumpTo?(quote), lines (1|2|3, default
2), label ("Quoted text"), dismissLabel ("Remove quote"). Renders null when
there is no quote, so it can stay permanently mounted above the composer.
- QuoteMessageButton(props): messageId, text?, author?, show ("auto" | "always",
default "auto"), children. "auto" renders only where the Selection API is
missing.
- Helpers: normalizeExcerpt(raw, maxLength) -> {text, truncated};
formatQuote(quote, {attribution, trailingBreak}) -> Markdown blockquote.
Behavior — detection (one listener for the whole transcript)
- The PROVIDER owns a single document-level `selectionchange` listener plus
capture-phase pointerdown / pointerup / pointercancel and a window `blur`.
Each <Quotable> only registers {host, content, messageId, author, text,
disabled} into a ref-held Map on mount and removes itself on unmount. One
listener per message would re-run a containment test per message on an event
that fires on every arrow key of a Shift+Arrow selection.
- Coalesce every evaluation into one requestAnimationFrame; cancel the pending
frame on unmount.
- Drag gate: pointerdown sets "dragging" and clears any candidate (a fresh press
ends the previous interaction); pointerup / pointercancel clears it and
re-evaluates. While dragging, no action is offered — the selection is not a
decision yet, and an action that chases the pointer is unusable. A drag
released outside the window never delivers pointerup, so window `blur` also
releases the gate; otherwise the gate would stay shut forever.
- A press whose target is inside `[data-quote-reply-action]` is NOT a new drag:
skip it, or the candidate is torn down before the click consumes it. Document
this attribute — a custom action built on floatingAction={false} needs it too.
- Ownership rule: take the LAST range of the selection and require
`content.contains(range.commonAncestorContainer)`. That single test also
proves both endpoints are inside the message, so a selection spilling into the
next message resolves to a shared ancestor and is ignored rather than
half-quoted. With nested quotables, the innermost containing one wins.
- Reject selections inside `input, textarea, select, [contenteditable]` — that
text belongs to the editor, not to the transcript.
- Read `selection.toString()` when rangeCount === 1 (it is rendering-aware and
inserts line breaks between blocks) and the range's own toString() otherwise,
so a Firefox multi-range selection cannot mix text from ranges the action is
not positioned against.
- Normalize: collapse whitespace runs to single spaces, trim, and cut at
maxLength — preferring the last word boundary in the tail unless it is so
early (< 60% of the budget) that the hard cut keeps more meaning. Append an
ellipsis and set truncated. Ignore results shorter than minLength (a stray
double-click is not a quote).
Behavior — the candidate and its locks
- The candidate freezes the EXCERPT at offer time, never at click time: clicking
is allowed to collapse the selection, so a payload derived on click would be
empty. This is the single most important rule in the component.
- Signature = hostId + range offsets + text. A `settled` ref holds the signature
the reader has already dealt with. Quoting sets it (one-shot lock: the same
range cannot produce a second quote, because the click deliberately does NOT
clear the browser selection and the next selectionchange would otherwise
re-offer it). Escape sets it too, dismissing the offer while leaving the
highlight alone — the reader said "not this", not "unselect".
- The lock is released as soon as the selection collapses or leaves the message,
so re-selecting the same words offers the action again.
- Geometry is host-relative: measure the range's bounding box and the host's
box, store centre-x, top, bottom and host width as offsets. The action is an
absolutely positioned child of the host, so it rides the content on scroll
with no scroll listener and no portal. Store the viewport-space top too, but
only for the one-time above/below flip; exclude it from the equality test, or
the action would jump sides while the page scrolls.
- Re-measure through a ResizeObserver on the active host only: in a chat the
message above can still be streaming, which moves the quoted paragraph with no
scroll and no resize event. Disconnect it when the candidate changes.
- Compare candidates on rounded geometry and bail out of the state update when
nothing moved; sub-pixel churn must not re-render the transcript.
Behavior — the action, the chip, the fallback
- The action is a real <button> rendered at the end of the host, so the
sequential focus navigation starting point (set by the selection itself) puts
it one Tab away; a `role="status"` sr-only line with CONSTANT wording
announces that it exists — constant, so it is spoken once per offer instead of
on every keystroke of a Shift+Arrow selection.
- Cancel the action's `mousedown` (not pointerdown — cancelling that is not
reliably click-safe across engines). The default action of mousedown moves the
caret and collapses the very selection the button is about to quote.
- Placement: above the selection by an 8px gap, flipped below when the box sits
within one action height of the viewport top. Clamp horizontally against the
host in a layout effect once the button has a width, so the corrected position
is the first one painted and a quote at the end of a line cannot hang outside
the message.
- QuoteChip is a `role="group"` with a left accent bar: attribution line
("Quoting <author>", plus "whole message" when origin is "message"), the
excerpt clamped to `lines`, and a dismiss button. With onJumpTo the excerpt
becomes a button that scrolls back to the source message; without it, it stays
inert text — never paint a clickable-looking excerpt with nowhere to go.
Escape inside the chip dismisses and stops propagation, so it does not also
close the dialog around it.
- Whole-message fallback: `selectionSupported` comes from useSyncExternalStore
with a server snapshot of true (never read `window` during render), so the
fallback button stays out of the server HTML. quoteMessage(messageId) reads
the registered `text` prop, else the CONTENT element's innerText — the action
and the live region are siblings of that element, never inside it, so the word
"Quote" from the floating button can never end up inside the quote.
Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground for the floating
action, bg-muted/40 and border-l-primary for the chip, text-muted-foreground
for attribution and the excerpt, hover:bg-accent hover:text-accent-foreground,
focus-visible:ring-2 focus-visible:ring-ring. No hex, no rgb(), no oklch().
- cn() merges every className; the wrapper takes the consumer's classes so a
message keeps its own bubble styling — the component only forces `relative`.
- The action's keyframes ship in a React 19 hoisted <style href precedence>
(no Tailwind config edits, deduped across instances) and the pop is
motion-reduce:[animation:none] — it appears instantly, still fully usable.
- The excerpt uses wrap-anywhere plus line-clamp so an unbroken URL cannot widen
the composer.
Customization levers
- maxLength / minLength: the excerpt budget and the "this was a stray click"
floor. Raise maxLength for a chip that shows two full sentences; drop
minLength to 1 if single-word quoting matters.
- floatingAction={false} turns the built-in bubble off while keeping detection:
render your own bar / toolbar / context menu from `selection` and
quoteSelection(), tagging it `data-quote-reply-action=""` with mousedown
cancelled.
- actionLabel and the chip's label / dismissLabel are the localization seams;
swap the Quote glyph for MessageSquareQuote or CornerUpLeft.
- ACTION_GAP / ACTION_HEIGHT / VIEWPORT_MARGIN / EDGE_MARGIN are the four
placement numbers — widen the gap for a chunkier action, raise the viewport
margin under a sticky header that would otherwise cover the button.
- Chip density: `lines` (1 for a tight mobile composer, 3 for a desktop one),
and swap the left accent bar for a full border or an avatar of the author.
- Multiple stacked quotes: keep an array in the parent, push in onQuote, and
render one QuoteChip per entry with an explicit `quote` prop — the provider
intentionally holds one pending quote.
- Payload shape: formatQuote() is the Markdown recipe; replace it with your own
serializer (a `quote_of` field, a permalink, a message-part id) — the Quote
object carries the messageId that any of those need.
- Persistence: onQuoteChange is where a draft-quote goes into localStorage so
the chip survives a reload alongside the draft it belongs to.Concepts
- Capture at offer time — the excerpt is frozen the moment the action appears, not when it is clicked; a click is allowed to collapse the selection, so anything read back from the DOM afterwards would be empty. Everything downstream works from that frozen
{text, messageId, author}. - Drag gate — while a pointer is down the selection is still being drawn, so no action is offered;
pointerupre-evaluates, and a windowblurreleases the gate for drags that ended outside the page and never deliveredpointerup. - Ownership by containment — a range belongs to the message that contains its
commonAncestorContainer; a selection that spills into the next message resolves to a shared ancestor instead and is ignored, rather than being silently half-quoted. - One-shot lock — quoting or dismissing marks that exact range as settled, because the click deliberately leaves the browser selection alone; the lock is released the instant the selection collapses, so re-selecting the same words offers the action again.
- Host-relative anchoring — coordinates are stored as offsets inside the message, so the action is an absolutely positioned child that rides the text on scroll without a portal, a scroll listener, or a stale fixed position; only reflow of the host itself (a message still streaming above) triggers a re-measure.
- Fallback over nothing — where the Selection API is missing there is no range to hang an action on, so a message action quotes the whole message; a transcript with no way at all to quote is worse than one that occasionally quotes too much.
Edit Message
In-place editor for a message that was already sent — it states how many replies the fork will drop, guards the draft on cancel, keeps it on a rejected resubmit, and never forks for unchanged text.
Conversation Summary
A compaction seam for long chats — a hairline marker that reports how many earlier messages were summarized, discloses the bullet summary, and offers a restore that can only fire once.