Let users select and quote text from messages. Full guide including backend handling and programmatic API.
Built-in Quote component
Get Started
The Quote registry component gives you everything you need out of the box, including a floating selection toolbar, composer quote preview, and inline quote display.
npx shadcn@latest add https://r.assistant-ui.com/quote.jsonMain Component
npm install @assistant-ui/react"use client";import { memo, type ComponentProps, type FC } from "react";import type { QuoteMessagePartComponent } from "@assistant-ui/react";import { ComposerPrimitive, SelectionToolbarPrimitive,} from "@assistant-ui/react";import { QuoteIcon, XIcon } from "lucide-react";import { cn } from "@/lib/utils";function QuoteBlockRoot({ className, ...props }: ComponentProps<"div">) { return ( <div data-slot="quote-block" className={cn("mb-2 flex items-start gap-1.5", className)} {...props} /> );}function QuoteBlockIcon({ className, ...props}: ComponentProps<typeof QuoteIcon>) { return ( <QuoteIcon data-slot="quote-block-icon" className={cn( "mt-0.5 size-3 shrink-0 text-muted-foreground/60", className, )} {...props} /> );}function QuoteBlockText({ className, ...props }: ComponentProps<"p">) { return ( <p data-slot="quote-block-text" className={cn( "line-clamp-2 min-w-0 text-muted-foreground/80 text-sm italic", className, )} {...props} /> );}/** * Renders quoted text in user messages. * * Pass this to `MessagePrimitive.Parts` as the `Quote` renderer. * * @example * ```tsx * <MessagePrimitive.Quote> * {(quote) => <QuoteBlock {...quote} />} * </MessagePrimitive.Quote> * ``` */const QuoteBlockImpl: QuoteMessagePartComponent = ({ text }) => { return ( <QuoteBlockRoot> <QuoteBlockIcon /> <QuoteBlockText>{text}</QuoteBlockText> </QuoteBlockRoot> );};const QuoteBlock = memo( QuoteBlockImpl,) as unknown as QuoteMessagePartComponent & { Root: typeof QuoteBlockRoot; Icon: typeof QuoteBlockIcon; Text: typeof QuoteBlockText;};QuoteBlock.displayName = "QuoteBlock";QuoteBlock.Root = QuoteBlockRoot;QuoteBlock.Icon = QuoteBlockIcon;QuoteBlock.Text = QuoteBlockText;function SelectionToolbarRoot({ className, ...props}: ComponentProps<typeof SelectionToolbarPrimitive.Root>) { return ( <SelectionToolbarPrimitive.Root data-slot="selection-toolbar" className={cn( "flex items-center gap-1 rounded-lg border bg-popover px-1 py-1 shadow-md", className, )} {...props} /> );}function SelectionToolbarQuote({ className, children, ...props}: ComponentProps<typeof SelectionToolbarPrimitive.Quote>) { return ( <SelectionToolbarPrimitive.Quote data-slot="selection-toolbar-quote" className={cn( "flex items-center gap-1.5 rounded-md px-2.5 py-1 text-popover-foreground text-sm transition-colors hover:bg-accent", className, )} {...props} > {children ?? ( <> <QuoteIcon className="size-3.5" /> Quote </> )} </SelectionToolbarPrimitive.Quote> );}/** * Floating toolbar that appears when text is selected in a message. * * Render anywhere inside `ThreadPrimitive.Root` (or any `AssistantRuntimeProvider` scope). * * @example * ```tsx * <ThreadPrimitive.Root> * <ThreadPrimitive.Viewport>...</ThreadPrimitive.Viewport> * <SelectionToolbar /> * </ThreadPrimitive.Root> * ``` */const SelectionToolbarImpl: FC<ComponentProps<typeof SelectionToolbarRoot>> = ({ className, ...props}) => { return ( <SelectionToolbarRoot className={className} {...props}> <SelectionToolbarQuote /> </SelectionToolbarRoot> );};const SelectionToolbar = memo( SelectionToolbarImpl,) as unknown as typeof SelectionToolbarImpl & { Root: typeof SelectionToolbarRoot; Quote: typeof SelectionToolbarQuote;};SelectionToolbar.displayName = "SelectionToolbar";SelectionToolbar.Root = SelectionToolbarRoot;SelectionToolbar.Quote = SelectionToolbarQuote;function ComposerQuotePreviewRoot({ className, ...props}: ComponentProps<typeof ComposerPrimitive.Quote>) { return ( <ComposerPrimitive.Quote data-slot="composer-quote" className={cn( "mx-3 mt-2 flex items-start gap-2 rounded-lg bg-muted/60 px-3 py-2", className, )} {...props} /> );}function ComposerQuotePreviewIcon({ className, ...props}: ComponentProps<typeof QuoteIcon>) { return ( <QuoteIcon data-slot="composer-quote-icon" className={cn( "mt-0.5 size-3.5 shrink-0 text-muted-foreground/70", className, )} {...props} /> );}function ComposerQuotePreviewText({ className, ...props}: ComponentProps<typeof ComposerPrimitive.QuoteText>) { return ( <ComposerPrimitive.QuoteText data-slot="composer-quote-text" className={cn( "line-clamp-2 min-w-0 flex-1 text-muted-foreground text-sm", className, )} {...props} /> );}function ComposerQuotePreviewDismiss({ className, children, ...props}: ComponentProps<typeof ComposerPrimitive.QuoteDismiss>) { const defaultClassName = "shrink-0 rounded-sm p-0.5 text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground"; return ( <ComposerPrimitive.QuoteDismiss data-slot="composer-quote-dismiss" asChild className={children ? className : undefined} {...props} > {children ?? ( <button type="button" aria-label="Dismiss quote" className={cn(defaultClassName, className)} > <XIcon className="size-3.5" /> </button> )} </ComposerPrimitive.QuoteDismiss> );}/** * Quote preview inside the composer. Only renders when a quote is set. * * Place inside `ComposerPrimitive.Root`. * * @example * ```tsx * <ComposerPrimitive.Root> * <ComposerQuotePreview /> * <ComposerPrimitive.Input /> * <ComposerPrimitive.Send /> * </ComposerPrimitive.Root> * ``` */const ComposerQuotePreviewImpl: FC< ComponentProps<typeof ComposerQuotePreviewRoot>> = ({ className, ...props }) => { return ( <ComposerQuotePreviewRoot className={className} {...props}> <ComposerQuotePreviewIcon /> <ComposerQuotePreviewText /> <ComposerQuotePreviewDismiss /> </ComposerQuotePreviewRoot> );};const ComposerQuotePreview = memo( ComposerQuotePreviewImpl,) as unknown as typeof ComposerQuotePreviewImpl & { Root: typeof ComposerQuotePreviewRoot; Icon: typeof ComposerQuotePreviewIcon; Text: typeof ComposerQuotePreviewText; Dismiss: typeof ComposerQuotePreviewDismiss;};ComposerQuotePreview.displayName = "ComposerQuotePreview";ComposerQuotePreview.Root = ComposerQuotePreviewRoot;ComposerQuotePreview.Icon = ComposerQuotePreviewIcon;ComposerQuotePreview.Text = ComposerQuotePreviewText;ComposerQuotePreview.Dismiss = ComposerQuotePreviewDismiss;export { QuoteBlock, QuoteBlockRoot, QuoteBlockIcon, QuoteBlockText, SelectionToolbar, SelectionToolbarRoot, SelectionToolbarQuote, ComposerQuotePreview, ComposerQuotePreviewRoot, ComposerQuotePreviewIcon, ComposerQuotePreviewText, ComposerQuotePreviewDismiss,};It ships three composable pieces:
- QuoteBlock renders quoted text inline in user messages
- SelectionToolbar is a floating toolbar that appears on text selection
- ComposerQuotePreview shows the pending quote inside the composer
See the Quote component page for full setup steps and API reference.
How It Works
When a user selects text in an assistant message, a floating toolbar appears with a Quote button. Clicking it calls composer.setQuote() to store the selection on the composer. The Quote component does this out of the box.
When the message is sent, the composer runtime automatically writes the quote to message.metadata.custom.quote and clears it from the composer.
On the backend, the route handler extracts the quote from metadata and surfaces it to the LLM. We export a helper called injectQuoteContext that handles this automatically for AI-SDK. Without this step, the quote appears in the UI but is not sent to the model as context. See Backend Handling for more info and alternatives.
Data Shape
type QuoteInfo = {
readonly text: string; // selected plain text
readonly messageId: string; // source message ID
};
// Stored at: message.metadata.custom.quoteBackend Handling
Quote data travels in message metadata, not content, so the LLM will not see it unless your backend extracts and surfaces it. The simplest path is injectQuoteContext, which prepends quoted text as a markdown blockquote before the message parts.
For provider-specific handling, work with the quote metadata directly.
Claude SDK Citations
Pass the quoted text as a citation source so Claude produces citations that reference it:
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
export async function POST(req: Request) {
const { messages } = await req.json();
const claudeMessages = messages.map((msg) => {
const quote = msg.metadata?.custom?.quote;
if (!quote?.text) {
return { role: msg.role, content: extractText(msg) };
}
return {
role: "user",
content: [
{
type: "text",
text: quote.text,
cache_control: { type: "ephemeral" },
citations: { enabled: true },
},
{
type: "text",
text: extractText(msg),
},
],
};
});
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
messages: claudeMessages,
});
// ... stream response back
}OpenAI Message Context
Inject the quote as additional context in the user message:
function injectQuoteForOpenAI(messages) {
return messages.map((msg) => {
const quote = msg.metadata?.custom?.quote;
if (!quote?.text || msg.role !== "user") return msg;
return {
...msg,
content: `[Referring to: "${quote.text}"]\n\n${msg.content}`,
};
});
}Reading Quote Data
Use useMessageQuote to access quote data in custom components:
import { useMessageQuote } from "@assistant-ui/react";
function CustomQuoteDisplay() {
const quote = useMessageQuote();
if (!quote) return null;
return (
<blockquote className="border-l-2 pl-3 text-sm text-muted-foreground">
{quote.text}
</blockquote>
);
}Programmatic API
Set or clear quotes via the composer runtime:
import { useAui } from "@assistant-ui/react";
function MyComponent() {
const aui = useAui();
const quoteText = () => {
aui.thread().composer().setQuote({
text: "The text to quote",
messageId: "msg-123",
});
};
const clearQuote = () => {
aui.thread().composer().setQuote(undefined);
};
return (
<>
<button onClick={quoteText}>Set Quote</button>
<button onClick={clearQuote}>Clear Quote</button>
</>
);
}Design Notes
- Single quote:
setQuotereplaces the current quote instead of appending. Only one quote can be active at a time. - Snapshot text: The selected text is captured when the quote is created and is not linked to the source message afterward.
- Cross-message selection: The toolbar only appears when the selection stays within a single message.
- Streaming messages: The toolbar still works while a message is streaming because it relies on the captured selection rather than message status.
isEmptyunchanged: A quote by itself does not make the composer non-empty. The user still needs to type a reply.- Scroll hides toolbar: The toolbar hides on scroll because its position would otherwise become stale.
Related
- Quote component: Installation, component setup, and API reference
- Message Editing: Edit user messages
- Thread: Main chat container