Installation
Complete project setup with react/aria, then install these components:
pnpm exec frontfriend download trace collapsible message bubble button icon -f react --primitive aria Reasoning opens while thinking and closes when the stream ends. A user’s open or closed choice takes priority. An empty reasoning part is a plain line until text arrives. Supply the optional duration from message metadata.
Usage
Complete example
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
Trace,
TraceLine,
TraceIcon,
TraceTitle,
TraceDescription,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import * as React from "react";
const REASONING =
"The deploy log has three phases. Install takes 38s and next build takes 52s, both normal for this project.\n\nImage optimization takes 3m 50s. It reruns on every deploy because its cache key includes the commit hash, so the cache never hits.";
const canvasStyle: React.CSSProperties = {
width: "100%",
maxWidth: 384,
minWidth: 0,
minHeight: 288,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 16,
paddingBlock: 24,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
};
function ReasoningLine({
text,
running,
duration,
defaultExpanded,
showIcon,
}: {
text: string;
running: boolean;
duration?: number;
defaultExpanded?: boolean;
showIcon?: boolean;
}) {
const [open, setOpen] = React.useState<boolean | undefined>(defaultExpanded);
const line = (
<TraceLine status={running ? "running" : undefined}>
{showIcon && (
<TraceIcon>
<Icon name="CircleCheck" />
</TraceIcon>
)}
<TraceTitle>{running ? "Thinking" : "Thought"}</TraceTitle>
{!running && duration !== undefined && (
<TraceDescription>{duration}s</TraceDescription>
)}
</TraceLine>
);
if (!text.length) return line;
return (
<TraceCollapsible isExpanded={open ?? running} onExpandedChange={setOpen}>
<TraceCollapsibleTrigger>{line}</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<div style={{ whiteSpace: "pre-wrap" }}>{text}</div>
</TraceCollapsibleContent>
</TraceCollapsible>
);
}
const ANSWER =
"Image optimization is the bottleneck. Key its cache on the lockfile and the image directory instead of the commit hash, and the deploy should take under two minutes.";
export default function AiReasoningExample() {
const [run, setRun] = React.useState(0);
const [position, setPosition] = React.useState(REASONING.length);
const running = position < REASONING.length;
React.useEffect(() => {
if (!running) return;
const timer = setTimeout(
() => setPosition((value) => Math.min(value + 4, REASONING.length)),
80,
);
return () => clearTimeout(timer);
}, [position, running]);
const parts = [
{
type: "reasoning" as const,
text: REASONING.slice(0, position),
state: running ? "streaming" : "done",
},
];
return (
<div style={canvasStyle}>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>Why is the checkout deploy so slow?</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent style={{ gap: 16 }}>
<Trace>
{parts
.filter((part) => part.type === "reasoning")
.map((part, index) => (
<ReasoningLine
key={`${run}-${index}`}
text={part.text}
running={part.state === "streaming"}
duration={5}
defaultExpanded={run === 0 ? true : undefined}
/>
))}
</Trace>
{!running && <p>{ANSWER}</p>}
</MessageContent>
</Message>
<Button
variant="outline"
size="sm"
style={{ marginTop: "auto", alignSelf: "flex-start" }}
isDisabled={running}
onPress={() => {
setRun((value) => value + 1);
setPosition(0);
}}
>
Replay
</Button>
</div>
);
} Duration
import {
Trace,
TraceLine,
TraceIcon,
TraceTitle,
TraceDescription,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import * as React from "react";
function ReasoningLine({
text,
running,
duration,
defaultExpanded,
showIcon,
}: {
text: string;
running: boolean;
duration?: number;
defaultExpanded?: boolean;
showIcon?: boolean;
}) {
const [open, setOpen] = React.useState<boolean | undefined>(defaultExpanded);
const line = (
<TraceLine status={running ? "running" : undefined}>
{showIcon && (
<TraceIcon>
<Icon name="CircleCheck" />
</TraceIcon>
)}
<TraceTitle>{running ? "Thinking" : "Thought"}</TraceTitle>
{!running && duration !== undefined && (
<TraceDescription>{duration}s</TraceDescription>
)}
</TraceLine>
);
if (!text.length) return line;
return (
<TraceCollapsible isExpanded={open ?? running} onExpandedChange={setOpen}>
<TraceCollapsibleTrigger>{line}</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<div style={{ whiteSpace: "pre-wrap" }}>{text}</div>
</TraceCollapsibleContent>
</TraceCollapsible>
);
}
export default function AiReasoningExample() {
return (
<div
style={{
width: "100%",
maxWidth: 384,
marginInline: "auto",
paddingBlock: 24,
}}
>
<Trace>
<ReasoningLine
text="The build log shows install and compile finish in under a minute. Image optimization takes almost four, and it reruns on every deploy because the cache key includes the commit hash."
running={false}
duration={12}
showIcon
/>
</Trace>
</div>
);
} AI SDK
This optional adapter accepts a UIMessage from your chat client. The complete example needs no AI SDK or provider. Install this extra package only for the adapter:
pnpm add ai
Render every reasoning part. A line runs only when both the chat and the part are streaming. Empty text stays a plain line. Each line follows the stream until the user changes its open state.
import type { UIMessage } from "ai";
import {
Trace,
TraceLine,
TraceIcon,
TraceTitle,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import * as React from "react";
function ReasoningPart({ text, running }: { text: string; running: boolean }) {
const [expanded, setExpanded] = React.useState<boolean>();
const line = (
<TraceLine status={running ? "running" : undefined}>
<TraceIcon>
<Icon name={running ? "LoaderCircle" : "CircleCheck"} />
</TraceIcon>
<TraceTitle>{running ? "Thinking" : "Thought"}</TraceTitle>
</TraceLine>
);
if (!text.length) return line;
return (
<TraceCollapsible
isExpanded={expanded ?? running}
onExpandedChange={setExpanded}
>
<TraceCollapsibleTrigger>{line}</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<div style={{ whiteSpace: "pre-wrap" }}>{text}</div>
</TraceCollapsibleContent>
</TraceCollapsible>
);
}
function ReasoningAdapter({
message,
streaming,
}: {
message: UIMessage;
streaming: boolean;
}) {
return (
<Trace>
{message.parts
.filter((part) => part.type === "reasoning")
.map((part, index) => (
<ReasoningPart
key={`${message.id}-${index}`}
text={part.text}
running={streaming && part.state === "streaming"}
/>
))}
</Trace>
);
}
const REASONING =
"The deploy log has three phases. Install takes 38s and next build takes 52s, both normal for this project.\n\nImage optimization takes 3m 50s. It reruns on every deploy because its cache key includes the commit hash, so the cache never hits.";
const ANSWER =
"Image optimization is the bottleneck. Key its cache on the lockfile and the image directory instead of the commit hash, and the deploy should take under two minutes.";
export default function AiReasoningExample() {
return (
<div
style={{
width: "100%",
maxWidth: 384,
marginInline: "auto",
paddingBlock: 24,
}}
>
<ReasoningAdapter
message={{
id: "answer",
role: "assistant",
parts: [
{ type: "reasoning", text: REASONING, state: "done" },
{ type: "text", text: ANSWER },
],
}}
streaming={false}
/>
</div>
);
} Components and API
This composition has no separate component API. Use the APIs for its installed components.
| Component | Target reference |
|---|---|
trace | React Aria API |
collapsible | React Aria API |
message | React Aria API |
bubble | React Aria API |
button | React Aria API |
icon | React Aria API |