Streaming Response
A stable response surface with completion actions, rendered content, and an expandable source summary.
Preview
"use client";
import { RotateCcw } from "lucide-react";
import { useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import type { CitationItem } from "@/components/agents/citations";
import { StreamingResponse } from "@/components/agents/streaming-response";
const PIECES = [
"A streaming response can link directly to ",
"Motion's React guide",
" while the rest of the answer continues to arrive.",
"The same response can preserve useful structure:",
"Links stay interactive as nearby text streams",
"Lists keep their spacing and hierarchy",
"Code remains readable without shifting the response",
"Set ",
"aria-busy",
" while new content is still arriving.",
'const status = complete ? "ready" : "streaming";',
] as const;
const STARTS = PIECES.map((_, index) =>
PIECES.slice(0, index).reduce((total, piece) => total + piece.length, 0),
);
const RESPONSE_LENGTH = PIECES.reduce((total, piece) => total + piece.length, 0);
const RESPONSE_MARKDOWN = `A streaming response can link directly to [Motion's React guide](https://motion.dev/docs/react) while the rest of the answer continues to arrive.
The same response can preserve useful structure:
- Links stay interactive as nearby text streams
- Lists keep their spacing and hierarchy
- Code remains readable without shifting the response
Set \`aria-busy\` while new content is still arriving.
\`\`\`tsx
const status = complete ? "ready" : "streaming";
\`\`\``;
const CHARACTERS_PER_SECOND = 110;
const RESPONSE_SOURCES: CitationItem[] = [
{
id: "motion-react",
title: "Motion for React",
domain: "motion.dev",
url: "https://motion.dev/docs/react",
},
{
id: "aria-busy",
title: "ARIA live regions",
domain: "developer.mozilla.org",
url: "https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-busy",
},
{
id: "react-rendering",
title: "Rendering elements",
domain: "react.dev",
url: "https://react.dev/learn/conditional-rendering",
},
];
function ResponseDemo({ onReplay }: { onReplay: () => void }) {
const reduce = useReducedMotion() ?? false;
const [cursor, setCursor] = useState(reduce ? RESPONSE_LENGTH : 0);
const [complete, setComplete] = useState(reduce);
const reveal = (index: number) =>
PIECES[index].slice(0, Math.max(0, cursor - STARTS[index]));
const started = (index: number) => cursor > STARTS[index];
useEffect(() => {
if (reduce) return;
const startedAt = performance.now();
let frame = 0;
let completionTimer: number | undefined;
const stream = (now: number) => {
const cursor = Math.min(
RESPONSE_LENGTH,
Math.floor(((now - startedAt) / 1000) * CHARACTERS_PER_SECOND),
);
setCursor(cursor);
if (cursor < RESPONSE_LENGTH) frame = requestAnimationFrame(stream);
else completionTimer = window.setTimeout(() => setComplete(true), 450);
};
frame = requestAnimationFrame(stream);
return () => {
cancelAnimationFrame(frame);
if (completionTimer) window.clearTimeout(completionTimer);
};
}, [reduce]);
return (
<StreamingResponse
status={complete ? "complete" : "streaming"}
copyText={RESPONSE_MARKDOWN}
onRetry={onReplay}
sources={RESPONSE_SOURCES}
>
<p>
{reveal(0)}
{started(1) ? (
<a
href="https://motion.dev/docs/react"
target="_blank"
rel="noreferrer noopener"
>
{reveal(1)}
</a>
) : null}
{reveal(2)}
</p>
{started(3) ? <p>{reveal(3)}</p> : null}
{started(4) ? (
<ul>
<li>{reveal(4)}</li>
{started(5) ? <li>{reveal(5)}</li> : null}
{started(6) ? <li>{reveal(6)}</li> : null}
</ul>
) : null}
{started(7) ? (
<p>
{reveal(7)}
{started(8) ? <code>{reveal(8)}</code> : null}
{reveal(9)}
</p>
) : null}
{started(10) ? (
<pre>
<code>{reveal(10)}</code>
</pre>
) : null}
</StreamingResponse>
);
}
export function StreamingResponsePreview() {
const [run, setRun] = useState(0);
return (
<div className="relative h-[500px] w-full max-w-xl">
<ResponseDemo key={run} onReplay={() => setRun((value) => value + 1)} />
<button
type="button"
onClick={() => setRun((value) => value + 1)}
className="absolute bottom-0 left-0 inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw className="size-3" />
Replay
</button>
</div>
);
}
"use client";
// beui.dev/components/agents/streaming-response
import {
Check,
ChevronDown,
Copy,
RotateCcw,
ThumbsDown,
ThumbsUp,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import {
type CitationItem,
CitationList,
CitationStack,
} from "@/components/agents/citations";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type StreamingResponseStatus = "streaming" | "complete" | "error";
export type StreamingResponseFeedback = "up" | "down" | null;
export interface StreamingResponseProps {
/** Rendered response content. Pass plain text or the output of a Markdown renderer. */
children: ReactNode;
status?: StreamingResponseStatus;
/** Plain-text value copied by the built-in copy action. */
copyText?: string;
/** Overrides the built-in clipboard action. */
onCopy?: () => void | Promise<void>;
onRetry?: () => void;
/** Optional sources shown as a compact footer disclosure after streaming. */
sources?: CitationItem[];
sourcesOpen?: boolean;
defaultSourcesOpen?: boolean;
onSourcesOpenChange?: (open: boolean) => void;
sourceIdPrefix?: string;
feedback?: StreamingResponseFeedback;
defaultFeedback?: StreamingResponseFeedback;
onFeedbackChange?: (feedback: StreamingResponseFeedback) => void;
/** Set false when a surrounding conversation log announces streamed text. */
announce?: boolean;
/** Hides the built-in completion actions without changing response status. */
showActions?: boolean;
className?: string;
contentClassName?: string;
actionsClassName?: string;
}
function ResponseAction({
label,
active = false,
onClick,
children,
}: {
label: string;
active?: boolean;
onClick: () => void;
children: ReactNode;
}) {
const reduce = useReducedMotion() ?? false;
return (
<motion.button
type="button"
aria-label={label}
title={label}
aria-pressed={label === "Helpful" || label === "Not helpful" ? active : undefined}
onClick={onClick}
whileTap={reduce ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
className={cn(
"grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
active && "bg-muted text-foreground",
)}
>
{children}
</motion.button>
);
}
export function StreamingResponse({
children,
status = "streaming",
copyText,
onCopy,
onRetry,
sources = [],
sourcesOpen,
defaultSourcesOpen = false,
onSourcesOpenChange,
sourceIdPrefix,
feedback,
defaultFeedback = null,
onFeedbackChange,
announce = true,
showActions = true,
className,
contentClassName,
actionsClassName,
}: StreamingResponseProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const [copied, setCopied] = useState(false);
const [internalFeedback, setInternalFeedback] =
useState<StreamingResponseFeedback>(defaultFeedback);
const [internalSourcesOpen, setInternalSourcesOpen] =
useState(defaultSourcesOpen);
const copyTimer = useRef<number | undefined>(undefined);
const currentFeedback = feedback ?? internalFeedback;
const currentSourcesOpen = sourcesOpen ?? internalSourcesOpen;
const streaming = status === "streaming";
const complete = status === "complete";
const canCopy = Boolean(copyText || onCopy);
const hasSources = sources.length > 0;
const shouldShowActions =
showActions && !streaming && (canCopy || onRetry || complete || hasSources);
const sourcesContentId = `${baseId}-sources`;
const resolvedSourcePrefix =
sourceIdPrefix ?? `response-source-${baseId.replace(/:/g, "")}`;
useEffect(
() => () => {
if (copyTimer.current) window.clearTimeout(copyTimer.current);
},
[],
);
const handleCopy = useCallback(async () => {
if (onCopy) await onCopy();
else if (copyText) await navigator.clipboard?.writeText(copyText);
setCopied(true);
if (copyTimer.current) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 1600);
}, [copyText, onCopy]);
const setFeedback = (next: Exclude<StreamingResponseFeedback, null>) => {
const value = currentFeedback === next ? null : next;
if (feedback === undefined) setInternalFeedback(value);
onFeedbackChange?.(value);
};
const setSourcesOpen = useCallback(
(next: boolean) => {
if (sourcesOpen === undefined) setInternalSourcesOpen(next);
onSourcesOpenChange?.(next);
},
[onSourcesOpenChange, sourcesOpen],
);
return (
<div
data-state={status}
aria-busy={streaming}
className={cn("w-full", className)}
>
<div
aria-live={announce ? "polite" : "off"}
className={cn(
"text-sm leading-6 text-foreground/90 [&_a]:font-medium [&_a]:underline [&_a]:underline-offset-4 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:space-y-1 [&_ol]:pl-5 [&_p+p]:mt-3 [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted/45 [&_pre]:p-3 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-5",
contentClassName,
)}
>
{children}
</div>
<AnimatePresence initial={false}>
{shouldShowActions ? (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0.12 : 0.22, ease: EASE_OUT }}
className="mt-3"
>
<div className={cn("flex items-center gap-0.5", actionsClassName)}>
{canCopy ? (
<ResponseAction
label={copied ? "Copied" : "Copy response"}
onClick={handleCopy}
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</ResponseAction>
) : null}
{onRetry ? (
<ResponseAction label="Retry response" onClick={onRetry}>
<RotateCcw className="size-3.5" />
</ResponseAction>
) : null}
{complete ? (
<>
<ResponseAction
label="Helpful"
active={currentFeedback === "up"}
onClick={() => setFeedback("up")}
>
<ThumbsUp className="size-3.5" />
</ResponseAction>
<ResponseAction
label="Not helpful"
active={currentFeedback === "down"}
onClick={() => setFeedback("down")}
>
<ThumbsDown className="size-3.5" />
</ResponseAction>
</>
) : null}
{hasSources ? (
<button
type="button"
aria-expanded={currentSourcesOpen}
aria-controls={sourcesContentId}
onClick={() => setSourcesOpen(!currentSourcesOpen)}
className="group ml-1 inline-flex min-h-7 items-center gap-2 rounded-md px-1.5 text-xs text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<CitationStack citations={sources} />
<span className="tabular-nums">
{sources.length} {sources.length === 1 ? "source" : "sources"}
</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentSourcesOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="text-muted-foreground/50 group-hover:text-muted-foreground"
>
<ChevronDown className="size-3" />
</motion.span>
</button>
) : null}
</div>
{hasSources ? (
<AgentDisclosure
id={sourcesContentId}
open={currentSourcesOpen}
>
<CitationList
citations={sources}
idPrefix={resolvedSourcePrefix}
className="mt-2 rounded-xl bg-muted p-2"
/>
</AgentDisclosure>
) : null}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** Resolve a website URL to its conventional root favicon location. */
export function getFaviconUrl(value: string) {
try {
return new URL("/favicon.ico", value).toString();
} catch {
return null;
}
}
Copy the source code
"use client";
// beui.dev/components/agents/streaming-response
import {
Check,
ChevronDown,
Copy,
RotateCcw,
ThumbsDown,
ThumbsUp,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import {
type CitationItem,
CitationList,
CitationStack,
} from "@/components/agents/citations";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import { EASE_OUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type StreamingResponseStatus = "streaming" | "complete" | "error";
export type StreamingResponseFeedback = "up" | "down" | null;
export interface StreamingResponseProps {
/** Rendered response content. Pass plain text or the output of a Markdown renderer. */
children: ReactNode;
status?: StreamingResponseStatus;
/** Plain-text value copied by the built-in copy action. */
copyText?: string;
/** Overrides the built-in clipboard action. */
onCopy?: () => void | Promise<void>;
onRetry?: () => void;
/** Optional sources shown as a compact footer disclosure after streaming. */
sources?: CitationItem[];
sourcesOpen?: boolean;
defaultSourcesOpen?: boolean;
onSourcesOpenChange?: (open: boolean) => void;
sourceIdPrefix?: string;
feedback?: StreamingResponseFeedback;
defaultFeedback?: StreamingResponseFeedback;
onFeedbackChange?: (feedback: StreamingResponseFeedback) => void;
/** Set false when a surrounding conversation log announces streamed text. */
announce?: boolean;
/** Hides the built-in completion actions without changing response status. */
showActions?: boolean;
className?: string;
contentClassName?: string;
actionsClassName?: string;
}
function ResponseAction({
label,
active = false,
onClick,
children,
}: {
label: string;
active?: boolean;
onClick: () => void;
children: ReactNode;
}) {
const reduce = useReducedMotion() ?? false;
return (
<motion.button
type="button"
aria-label={label}
title={label}
aria-pressed={label === "Helpful" || label === "Not helpful" ? active : undefined}
onClick={onClick}
whileTap={reduce ? undefined : { scale: 0.9 }}
transition={SPRING_PRESS}
className={cn(
"grid size-7 place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
active && "bg-muted text-foreground",
)}
>
{children}
</motion.button>
);
}
export function StreamingResponse({
children,
status = "streaming",
copyText,
onCopy,
onRetry,
sources = [],
sourcesOpen,
defaultSourcesOpen = false,
onSourcesOpenChange,
sourceIdPrefix,
feedback,
defaultFeedback = null,
onFeedbackChange,
announce = true,
showActions = true,
className,
contentClassName,
actionsClassName,
}: StreamingResponseProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const [copied, setCopied] = useState(false);
const [internalFeedback, setInternalFeedback] =
useState<StreamingResponseFeedback>(defaultFeedback);
const [internalSourcesOpen, setInternalSourcesOpen] =
useState(defaultSourcesOpen);
const copyTimer = useRef<number | undefined>(undefined);
const currentFeedback = feedback ?? internalFeedback;
const currentSourcesOpen = sourcesOpen ?? internalSourcesOpen;
const streaming = status === "streaming";
const complete = status === "complete";
const canCopy = Boolean(copyText || onCopy);
const hasSources = sources.length > 0;
const shouldShowActions =
showActions && !streaming && (canCopy || onRetry || complete || hasSources);
const sourcesContentId = `${baseId}-sources`;
const resolvedSourcePrefix =
sourceIdPrefix ?? `response-source-${baseId.replace(/:/g, "")}`;
useEffect(
() => () => {
if (copyTimer.current) window.clearTimeout(copyTimer.current);
},
[],
);
const handleCopy = useCallback(async () => {
if (onCopy) await onCopy();
else if (copyText) await navigator.clipboard?.writeText(copyText);
setCopied(true);
if (copyTimer.current) window.clearTimeout(copyTimer.current);
copyTimer.current = window.setTimeout(() => setCopied(false), 1600);
}, [copyText, onCopy]);
const setFeedback = (next: Exclude<StreamingResponseFeedback, null>) => {
const value = currentFeedback === next ? null : next;
if (feedback === undefined) setInternalFeedback(value);
onFeedbackChange?.(value);
};
const setSourcesOpen = useCallback(
(next: boolean) => {
if (sourcesOpen === undefined) setInternalSourcesOpen(next);
onSourcesOpenChange?.(next);
},
[onSourcesOpenChange, sourcesOpen],
);
return (
<div
data-state={status}
aria-busy={streaming}
className={cn("w-full", className)}
>
<div
aria-live={announce ? "polite" : "off"}
className={cn(
"text-sm leading-6 text-foreground/90 [&_a]:font-medium [&_a]:underline [&_a]:underline-offset-4 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:space-y-1 [&_ol]:pl-5 [&_p+p]:mt-3 [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:border [&_pre]:border-border [&_pre]:bg-muted/45 [&_pre]:p-3 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-5",
contentClassName,
)}
>
{children}
</div>
<AnimatePresence initial={false}>
{shouldShowActions ? (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0.12 : 0.22, ease: EASE_OUT }}
className="mt-3"
>
<div className={cn("flex items-center gap-0.5", actionsClassName)}>
{canCopy ? (
<ResponseAction
label={copied ? "Copied" : "Copy response"}
onClick={handleCopy}
>
{copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
)}
</ResponseAction>
) : null}
{onRetry ? (
<ResponseAction label="Retry response" onClick={onRetry}>
<RotateCcw className="size-3.5" />
</ResponseAction>
) : null}
{complete ? (
<>
<ResponseAction
label="Helpful"
active={currentFeedback === "up"}
onClick={() => setFeedback("up")}
>
<ThumbsUp className="size-3.5" />
</ResponseAction>
<ResponseAction
label="Not helpful"
active={currentFeedback === "down"}
onClick={() => setFeedback("down")}
>
<ThumbsDown className="size-3.5" />
</ResponseAction>
</>
) : null}
{hasSources ? (
<button
type="button"
aria-expanded={currentSourcesOpen}
aria-controls={sourcesContentId}
onClick={() => setSourcesOpen(!currentSourcesOpen)}
className="group ml-1 inline-flex min-h-7 items-center gap-2 rounded-md px-1.5 text-xs text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<CitationStack citations={sources} />
<span className="tabular-nums">
{sources.length} {sources.length === 1 ? "source" : "sources"}
</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentSourcesOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="text-muted-foreground/50 group-hover:text-muted-foreground"
>
<ChevronDown className="size-3" />
</motion.span>
</button>
) : null}
</div>
{hasSources ? (
<AgentDisclosure
id={sourcesContentId}
open={currentSourcesOpen}
>
<CitationList
citations={sources}
idPrefix={resolvedSourcePrefix}
className="mt-2 rounded-xl bg-muted p-2"
/>
</AgentDisclosure>
) : null}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
"use client";
import { motion, type HTMLMotionProps, useReducedMotion } from "motion/react";
import type { CSSProperties } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface AgentDisclosureProps
extends Omit<HTMLMotionProps<"div">, "animate" | "initial"> {
open: boolean;
openHeight?: CSSProperties["height"];
}
/** Shared transform-only reveal for collapsible agent content. */
export function AgentDisclosure({
open,
openHeight = "auto",
className,
style,
transition,
...props
}: AgentDisclosureProps) {
const reduce = useReducedMotion() ?? false;
return (
<motion.div
{...props}
aria-hidden={!open}
inert={!open}
initial={false}
animate={
reduce
? { opacity: open ? 1 : 0 }
: {
opacity: open ? 1 : 0,
clipPath: open ? "inset(0 0 0% 0)" : "inset(0 0 100% 0)",
y: open ? 0 : -4,
}
}
transition={
transition ?? {
duration: reduce ? 0 : open ? 0.22 : 0.14,
ease: EASE_OUT,
}
}
className={cn("overflow-hidden", className)}
style={{
...style,
height: open ? openHeight : 0,
pointerEvents: open ? undefined : "none",
transformOrigin: "top",
}}
/>
);
}
"use client";
import { BookOpenText, ChevronDown, ExternalLink, Globe2 } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useId,
useState,
} from "react";
import { AgentDisclosure } from "@/components/agents/agent-disclosure";
import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import { getFaviconUrl } from "@/lib/favicon";
import { cn } from "@/lib/utils";
export interface CitationItem {
id: string;
title: ReactNode;
domain?: ReactNode;
url?: string;
}
export interface CitationsProps {
citations: CitationItem[];
title?: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
idPrefix?: string;
className?: string;
}
export interface CitationProps {
citationId: string;
index: number;
/** Must match the related Citations idPrefix. */
idPrefix: string;
className?: string;
}
export interface CitationListProps {
citations: CitationItem[];
idPrefix?: string;
className?: string;
}
export interface CitationStackProps {
citations: CitationItem[];
limit?: number;
className?: string;
}
function citationTargetId(prefix: string, citationId: string) {
return `${prefix}-${citationId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
}
export function Citation({
citationId,
index,
idPrefix,
className,
}: CitationProps) {
return (
<a
href={`#${citationTargetId(idPrefix, citationId)}`}
aria-label={`View citation ${index}`}
className={cn(
"mx-0.5 inline-flex min-w-4 -translate-y-0.5 items-center justify-center rounded-md bg-muted/60 px-1 py-0.5 text-[10px] font-semibold leading-none text-muted-foreground no-underline outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
{index}
</a>
);
}
export function CitationFavicon({
url,
className,
}: {
url?: string;
className?: string;
}) {
const favicon = url ? getFaviconUrl(url) : null;
const [failedUrl, setFailedUrl] = useState<string | null>(null);
return (
<span
aria-hidden="true"
className={cn(
"grid size-5 shrink-0 place-items-center text-muted-foreground",
className,
)}
>
{favicon && failedUrl !== favicon ? (
// biome-ignore lint/performance/noImgElement: Dynamic cross-site favicons keep this framework-agnostic registry component portable.
<img
src={favicon}
alt=""
width={16}
height={16}
referrerPolicy="no-referrer"
onError={() => setFailedUrl(favicon)}
className="size-4 rounded-sm object-contain"
/>
) : (
<Globe2 className="size-3.5" />
)}
</span>
);
}
export function CitationStack({
citations,
limit = 3,
className,
}: CitationStackProps) {
return (
<span
aria-hidden="true"
className={cn("flex -space-x-1.5", className)}
>
{citations.slice(0, limit).map((citation) => (
<CitationFavicon
key={citation.id}
url={citation.url}
className="size-6 rounded-full bg-background ring-2 ring-background"
/>
))}
</span>
);
}
function CitationRow({
citation,
index,
idPrefix,
}: {
citation: CitationItem;
index: number;
idPrefix: string;
}) {
const content = (
<>
<CitationFavicon url={citation.url} />
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="truncate text-sm font-medium text-foreground/80 transition-colors group-hover/citation:text-foreground">
{citation.title}
</span>
{citation.domain ? (
<span className="min-w-0 truncate text-xs text-muted-foreground/60">
{citation.domain}
</span>
) : null}
</span>
<span className="flex shrink-0 items-center gap-1.5">
<span className="grid size-5 place-items-center rounded-md bg-foreground/[0.05] text-[10px] font-semibold tabular-nums text-muted-foreground">
{index}
</span>
{citation.url ? (
<ExternalLink className="size-3.5 text-muted-foreground/40 transition-colors group-hover/citation:text-muted-foreground" />
) : null}
</span>
</>
);
const className =
"group/citation flex items-center gap-2 rounded-md px-1.5 py-1 outline-none focus-visible:ring-2 focus-visible:ring-ring";
const id = citationTargetId(idPrefix, citation.id);
return citation.url ? (
<a
id={id}
href={citation.url}
target="_blank"
rel="noreferrer noopener"
className={className}
>
{content}
</a>
) : (
<div id={id} className={className}>
{content}
</div>
);
}
export function CitationList({
citations,
idPrefix,
className,
}: CitationListProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const resolvedPrefix =
idPrefix ?? `citation-list-${baseId.replace(/:/g, "")}`;
return (
<div className={cn("grid gap-0.5", className)}>
<AnimatePresence mode="popLayout">
{citations.map((citation, index) => (
<motion.div
layout="position"
key={citation.id}
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -3 }}
transition={
reduce
? { duration: 0 }
: {
opacity: { duration: 0.18, ease: EASE_OUT },
y: SPRING_LAYOUT,
layout: SPRING_LAYOUT,
}
}
>
<CitationRow
citation={citation}
index={index + 1}
idPrefix={resolvedPrefix}
/>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
export function Citations({
citations,
title = "Sources",
open,
defaultOpen = false,
onOpenChange,
idPrefix,
className,
}: CitationsProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const contentId = `${baseId}-content`;
const resolvedPrefix =
idPrefix ?? `citation-${baseId.replace(/:/g, "")}`;
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const currentOpen = open ?? internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[onOpenChange, open],
);
return (
<div className={cn("w-full text-sm", className)}>
<button
type="button"
aria-expanded={currentOpen}
aria-controls={contentId}
onClick={() => setOpen(!currentOpen)}
className="group -ml-1 flex min-h-8 items-center gap-2 rounded-lg px-1 text-left text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<BookOpenText className="size-4" />
<span className="font-medium">{title}</span>
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold tabular-nums">
{citations.length}
</span>
<motion.span
aria-hidden="true"
animate={{ rotate: currentOpen ? 180 : 0 }}
transition={reduce ? { duration: 0 } : SPRING_SWAP}
className="text-muted-foreground/60"
>
<ChevronDown className="size-3.5" />
</motion.span>
</button>
<AgentDisclosure
id={contentId}
open={currentOpen}
>
<CitationList
citations={citations}
idPrefix={resolvedPrefix}
className="mt-1"
/>
</AgentDisclosure>
</div>
);
}
API Reference
childrenReactNodeRendered response content. Pass plain text or the output of a Markdown renderer.
—status?"complete" | "error" | "streaming"streamingcopyText?stringPlain-text value copied by the built-in copy action.
—onCopy?(() => void | Promise<void>)Overrides the built-in clipboard action.
—onRetry?(() => void)—sources?CitationItem[]Optional sources shown as a compact footer disclosure after streaming.
[]sourcesOpen?boolean—defaultSourcesOpen?booleanfalseonSourcesOpenChange?((open: boolean) => void)—sourceIdPrefix?string—feedback?StreamingResponseFeedback—defaultFeedback?StreamingResponseFeedbacknullonFeedbackChange?((feedback: StreamingResponseFeedback) => void)—announce?booleanSet false when a surrounding conversation log announces streamed text.
trueshowActions?booleanHides the built-in completion actions without changing response status.
trueclassName?string—contentClassName?string—actionsClassName?string—Composition
Keep response state inside the message surface while the outer row remains stable.
Message
└── MessageContent
└── MessageBubble
└── StreamingResponseNote: Message places the response in a stable assistant row. Citations connects completed claims to supporting sources. Code Block renders structured source inside a rich response.
How it works
A response has two distinct phases: content is still arriving, then the answer becomes available for action. The layout should remain stable across that boundary while rich content continues to render normally.
Updated