Installation
Complete project setup with react/base, then install these components:
pnpm exec frontfriend download trace collapsible message bubble button icon -f react --primitive base Collect source-url parts after the answer and remove duplicate URLs. If a title is absent, show the hostname. Each link opens in a new tab and includes an accessible notice.
Usage
Complete example
import { Message, MessageContent } from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceLine,
TraceIcon,
TraceTitle,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
import { Button } from "@/components/ui/button";
import * as React from "react";
type SourcePart = {
type: "source-url";
sourceId: string;
url: string;
title?: string;
};
const SOURCES: SourcePart[] = [
{
type: "source-url",
sourceId: "source-0",
url: "https://docs.astro.build/en/install-and-setup/",
title: "Install Astro | docs.astro.build",
},
{
type: "source-url",
sourceId: "source-1",
url: "https://www.npmjs.com/package/create-astro",
title: "create-astro | npmjs.com",
},
{
type: "source-url",
sourceId: "source-2",
url: "https://docs.astro.build/en/install-and-setup/",
title: "Install Astro | docs.astro.build",
},
{
type: "source-url",
sourceId: "source-3",
url: "https://pnpm.io/cli/create",
},
];
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,
};
const ANSWER =
"Run pnpm create astro@latest and follow the prompts. It scaffolds the project and installs the dependencies.";
export default function AiSourcesExample() {
const [count, setCount] = React.useState(SOURCES.length);
const running = count < SOURCES.length;
React.useEffect(() => {
if (!running) return;
const timer = setTimeout(() => setCount((value) => value + 1), 500);
return () => clearTimeout(timer);
}, [count, running]);
const parts = SOURCES.slice(0, count);
const sources = parts
.filter((part) => part.type === "source-url")
.filter(
(part, index, all) =>
all.findIndex((other) => other.url === part.url) === index,
);
return (
<div style={canvasStyle}>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>How do I start an Astro project with pnpm?</BubbleContent>
</Bubble>
</MessageContent>
</Message>
<Message>
<MessageContent style={{ gap: 16 }}>
<p>{ANSWER}</p>
{sources.length > 0 && (
<Trace>
<TraceCollapsible defaultOpen>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Icon name="Globe" />
</TraceIcon>
<TraceTitle>
Used {sources.length}{" "}
{sources.length === 1 ? "source" : "sources"}
</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
{sources.map((source) => (
<a
key={source.url}
href={source.url}
target="_blank"
rel="noreferrer"
>
<TraceLine>
<TraceTitle>
{source.title ?? new URL(source.url).hostname}
<span
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
}}
>
{" "}
(opens in a new tab)
</span>
</TraceTitle>
<TraceIcon>
<Icon name="ArrowUpRight" />
</TraceIcon>
</TraceLine>
</a>
))}
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
)}
</MessageContent>
</Message>
<Button
variant="outline"
size="sm"
style={{ marginTop: "auto", alignSelf: "flex-start" }}
disabled={running}
onClick={() => setCount(0)}
>
Replay
</Button>
</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
Read source-url parts after the answer, remove duplicate URLs, and use the hostname when the title is absent. Links include the new-tab notice.
import type { UIMessage } from "ai";
import {
Trace,
TraceCollapsible,
TraceCollapsibleTrigger,
TraceLine,
TraceIcon,
TraceTitle,
TraceCollapsibleContent,
} from "@/components/ui/trace";
import { Icon } from "@/components/ui/icon";
function SourcesAdapter({ message }: { message: UIMessage }) {
const sources = message.parts
.filter((part) => part.type === "source-url")
.filter(
(part, index, all) =>
all.findIndex((other) => other.url === part.url) === index,
);
return sources.length > 0 ? (
<Trace>
<TraceCollapsible defaultOpen>
<TraceCollapsibleTrigger>
<TraceLine>
<TraceIcon>
<Icon name="Globe" />
</TraceIcon>
<TraceTitle>
Used {sources.length}{" "}
{sources.length === 1 ? "source" : "sources"}
</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<Trace>
{sources.map((source) => (
<a
key={source.url}
href={source.url}
target="_blank"
rel="noreferrer"
>
<TraceLine>
<TraceTitle>
{source.title ?? new URL(source.url).hostname}
<span
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
}}
>
{" "}
(opens in a new tab)
</span>
</TraceTitle>
<TraceIcon>
<Icon name="ArrowUpRight" />
</TraceIcon>
</TraceLine>
</a>
))}
</Trace>
</TraceCollapsibleContent>
</TraceCollapsible>
</Trace>
) : null;
}
const ANSWER =
"Run pnpm create astro@latest and follow the prompts. It scaffolds the project and installs the dependencies.";
type SourcePart = {
type: "source-url";
sourceId: string;
url: string;
title?: string;
};
const SOURCES: SourcePart[] = [
{
type: "source-url",
sourceId: "source-0",
url: "https://docs.astro.build/en/install-and-setup/",
title: "Install Astro | docs.astro.build",
},
{
type: "source-url",
sourceId: "source-1",
url: "https://www.npmjs.com/package/create-astro",
title: "create-astro | npmjs.com",
},
{
type: "source-url",
sourceId: "source-2",
url: "https://docs.astro.build/en/install-and-setup/",
title: "Install Astro | docs.astro.build",
},
{
type: "source-url",
sourceId: "source-3",
url: "https://pnpm.io/cli/create",
},
];
export default function AiSourcesExample() {
return (
<div
style={{
width: "100%",
maxWidth: 384,
marginInline: "auto",
paddingBlock: 24,
}}
>
<SourcesAdapter
message={{
id: "answer",
role: "assistant",
parts: [{ type: "text", text: ANSWER }, ...SOURCES],
}}
/>
</div>
);
} Components and API
This composition has no separate component API. Use the APIs for its installed components.
| Component | Target reference |
|---|---|
trace | Base UI API |
collapsible | Base UI API |
message | Base UI API |
bubble | Base UI API |
button | Base UI API |
icon | Base UI API |