AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Quote as QuoteGlyph, X } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Model
 *
 * A quote is captured the moment the reader asks for it, never read back off
 * the DOM afterwards: clicking anything is allowed to collapse the selection,
 * and a payload derived at click time would then be empty. Everything the
 * composer needs is frozen into this object at detection time.
 * -------------------------------------------------------------------------- */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/quote-reply.json

Prompt

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; pointerup re-evaluates, and a window blur releases the gate for drags that ended outside the page and never delivered pointerup.
  • 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.

On This Page