Installation
Complete project setup with react/aria, then install these components:
pnpm exec frontfriend download message bubble button tooltip icon input-group -f react --primitive aria Copy reports success only after the clipboard write succeeds. Feedback buttons expose their pressed state. Retry clears the feedback and copy states. The reply uses plain text; no Markdown renderer is required.
Optional AI SDK adapter
Message Parts accepts the structural id, role, and parts fields from an AI SDK UIMessage. Pass a message from your own chat client to the adapter. It renders text parts as escaped text, hides step-start markers, and skips unsupported parts. Add explicit renderers for tools or sources when your application supports them. This adapter adds no AI SDK dependency.
Usage
Complete example
import {
Message,
MessageContent,
MessageFooter,
MessageHeader,
} from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { TooltipTrigger, Tooltip } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import {
InputGroup,
InputGroupTextarea,
InputGroupAddon,
InputGroupButton,
} from "@/components/ui/input-group";
import * as React from "react";
function AiMessageAction({
label,
icon,
pressed,
onActivate,
}: {
label: string;
icon: string;
pressed?: boolean;
onActivate: () => void;
}) {
return (
<TooltipTrigger>
<Button
variant="ghost"
size="icon-sm"
aria-label={label}
aria-pressed={pressed}
onPress={onActivate}
>
<Icon name={icon} />
</Button>
<Tooltip>{label}</Tooltip>
</TooltipTrigger>
);
}
export default function AiMessageExample() {
const [prompt, setPrompt] = React.useState(
"How do I add a settings dialog? It should open from the gear icon in the header.",
);
const [draft, setDraft] = React.useState(prompt);
const [editing, setEditing] = React.useState(false);
const editorRef = React.useRef<HTMLTextAreaElement>(null);
const [feedback, setFeedback] = React.useState<"good" | "bad" | null>(null);
const [copied, setCopied] = React.useState<string | null>(null);
const messageRef = React.useRef<HTMLSpanElement>(null);
const replyText = [
"Install Dialog, then connect its open state to the settings button.",
"Implementation",
"1. Place the settings content inside DialogContent.",
"2. Wrap the header button with DialogTrigger.",
"3. Give the dialog a title and test the keyboard path.",
].join("\n\n");
const [status, setStatus] = React.useState("");
React.useEffect(() => {
if (editing) editorRef.current?.focus();
}, [editing]);
async function copy(value: string, source: string) {
try {
await navigator.clipboard.writeText(value);
setCopied(source);
setStatus(`Copied ${source}.`);
} catch {
setStatus("Copy failed. Select the text and copy it manually.");
}
}
return (
<div
style={{
width: "100%",
maxWidth: 560,
minWidth: 0,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 28,
paddingBlock: 16,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
}}
>
<Message align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>
<span ref={messageRef} tabIndex={-1}>
{prompt}
</span>
</BubbleContent>
</Bubble>
<MessageFooter>
<AiMessageAction
label={copied === "message" ? "Copied message" : "Copy message"}
icon={copied === "message" ? "Check" : "Copy"}
onActivate={() => void copy(prompt, "message")}
/>
<AiMessageAction
label="Edit message"
icon="Pencil"
onActivate={() => {
setDraft(prompt);
setEditing(true);
}}
/>
</MessageFooter>
{editing && (
<form
onSubmit={(event) => {
event.preventDefault();
if (!draft.trim()) return;
setPrompt(draft.trim());
setEditing(false);
setStatus("Message updated.");
messageRef.current?.focus();
}}
>
<InputGroup>
<InputGroupTextarea
ref={editorRef}
aria-label="Edit message"
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<InputGroupAddon align="block-end">
<InputGroupButton type="submit" variant="default">
Save
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
)}
</MessageContent>
</Message>
<Message>
<MessageContent>
<MessageHeader>Assistant</MessageHeader>
<p>Install Dialog, then connect its open state to the settings button.</p>
<strong>Implementation</strong>
<ol
style={{
paddingInlineStart: 20,
display: "grid",
gap: 6,
listStyleType: "decimal",
}}
>
<li>Place the settings content inside DialogContent.</li>
<li>Wrap the header button with DialogTrigger.</li>
<li>Give the dialog a title and test the keyboard path.</li>
</ol>
<MessageFooter>
<AiMessageAction
label={copied === "reply" ? "Copied reply" : "Copy reply"}
icon={copied === "reply" ? "Check" : "Copy"}
onActivate={() => void copy(replyText, "reply")}
/>
<AiMessageAction
label="Good response"
icon="ThumbsUp"
pressed={feedback === "good"}
onActivate={() =>
setFeedback((value) => (value === "good" ? null : "good"))
}
/>
<AiMessageAction
label="Bad response"
icon="ThumbsDown"
pressed={feedback === "bad"}
onActivate={() =>
setFeedback((value) => (value === "bad" ? null : "bad"))
}
/>
<AiMessageAction
label="Retry"
icon="RotateCw"
onActivate={() => {
setFeedback(null);
setCopied(null);
setStatus("Reply regenerated.");
}}
/>
</MessageFooter>
</MessageContent>
</Message>
<p
role="status"
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
border: 0,
}}
>
{status}
</p>
</div>
);
} Message Parts
import {
Message,
MessageContent,
MessageHeader,
} from "@/components/ui/message";
type AdapterMessage = {
id: string;
role: string;
parts: Array<{
type: string;
text?: string;
}>;
};
function MessagePartsAdapter({ message }: { message: AdapterMessage }) {
return (
<Message align={message.role === "user" ? "end" : "start"}>
<MessageContent>
<MessageHeader>
{message.role === "user" ? "You" : "Assistant"}
</MessageHeader>
{message.parts.map((part, index) =>
part.type === "text" ? (
<p key={index}>{part.text}</p>
) : part.type === "step-start" ? null : null,
)}
</MessageContent>
</Message>
);
}
export default function AiMessageExample() {
return (
<MessagePartsAdapter
message={{
id: "reply",
role: "assistant",
parts: [
{ type: "step-start" },
{
type: "text",
text: "Install Dialog and connect it to the settings button.",
},
],
}}
/>
);
} Components and API
This composition has no separate component API. Use the APIs for its installed components.
| Component | Target reference |
|---|---|
message | React Aria API |
bubble | React Aria API |
button | React Aria API |
tooltip | React Aria API |
icon | React Aria API |
input-group | React Aria API |