Installation
Complete project setup with react/aria, then install these components:
pnpm exec frontfriend download message-scroller message bubble input-group button icon empty dropdown-menu tooltip -f react --primitive aria The viewport keeps the thread in one scroll area. Enter sends; Shift+Enter adds a line. Input composition does not send a message. Reset starts a new thread. Text is escaped by the framework. Connect your own response data when you need a provider.
Usage
Complete example
import { TooltipTrigger, Tooltip } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import {
MessageScrollerProvider,
MessageScroller,
MessageScrollerViewport,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerButton,
} from "@/components/ui/message-scroller";
import {
Message,
MessageContent,
MessageHeader,
} from "@/components/ui/message";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
Empty,
EmptyHeader,
EmptyMedia,
EmptyTitle,
EmptyDescription,
} from "@/components/ui/empty";
import {
InputGroup,
InputGroupTextarea,
InputGroupAddon,
InputGroupButton,
} from "@/components/ui/input-group";
import {
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
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>
);
}
function AiContextMenu({ onChoose }: { onChoose: (value: string) => void }) {
return (
<DropdownMenuTrigger>
<InputGroupButton
type="button"
size="icon-sm"
variant="outline"
aria-label="Add context"
>
<Icon name="Plus" />
</InputGroupButton>
<DropdownMenu aria-label="Add context">
<DropdownMenuItem
id="files"
onAction={() => onChoose("Photos and files")}
>
<Icon name="Paperclip" />
Add Photos & Files
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
id="research"
onAction={() => onChoose("Deep research")}
>
<Icon name="Search" />
Deep Research
</DropdownMenuItem>
<DropdownMenuItem id="web" onAction={() => onChoose("Web search")}>
<Icon name="Globe" />
Web Search
</DropdownMenuItem>
</DropdownMenu>
</DropdownMenuTrigger>
);
}
export default function AiChatExample() {
const [text, setText] = React.useState(
"Make it follow the system setting by default.",
);
const [context, setContext] = React.useState("");
const [messages, setMessages] = React.useState([
{
id: "user-0",
role: "user",
text: "Add a dark mode toggle to the settings page.",
},
{ id: "assistant-0", role: "assistant", text: "The theme provider is already in place. I added the toggle next to Appearance." },
]);
function send(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!text.trim()) return;
setMessages((previous) => [
...previous,
{ id: crypto.randomUUID(), role: "user", text: text.trim() },
{
id: crypto.randomUUID(),
role: "assistant",
text: "The theme provider is already in place. I added the toggle next to Appearance.",
},
]);
setText("");
}
return (
<div
style={{
width: "100%",
maxWidth: 640,
minWidth: 0,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 20,
paddingBlock: 16,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
}}
>
<header
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "start",
gap: 16,
}}
>
<div style={{ display: "grid", gap: 4 }}>
<strong>New Thread</strong>
<p>Ask the agent to change your code.</p>
</div>
<AiMessageAction
label="Reset conversation"
icon="RotateCw"
onActivate={() => {
setMessages([]);
setText("");
setContext("");
}}
/>
</header>
<MessageScrollerProvider autoScroll>
{messages.length ? (
<MessageScroller>
<MessageScrollerViewport
style={{ height: "auto", maxHeight: 400 }}
tabIndex={0}
aria-label="Conversation"
>
<MessageScrollerContent style={{ gap: 24 }}>
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === "user"}
>
<Message align={message.role === "user" ? "end" : "start"}>
<MessageContent>
{message.role === "user" ? (
<Bubble align="end" variant="muted">
<BubbleContent>{message.text}</BubbleContent>
</Bubble>
) : (
<>
<MessageHeader>Assistant</MessageHeader>
<p>{message.text}</p>
<strong>Changes</strong>
<ul
style={{
paddingInlineStart: 20,
listStyleType: "disc",
display: "grid",
gap: 6,
}}
>
<li>Added Light, Dark, and System options.</li>
<li>
Placed the toggle next to the Appearance
heading.
</li>
</ul>
<p>
Use the settings page to check each theme before
you ship.
</p>
</>
)}
</MessageContent>
</Message>
</MessageScrollerItem>
))}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
) : (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Icon name="MessageCircle" />
</EmptyMedia>
<EmptyTitle>What should we build?</EmptyTitle>
<EmptyDescription>
Describe a change to your code. Send a message to start the
conversation.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</MessageScrollerProvider>
<form onSubmit={send}>
<InputGroup>
<InputGroupTextarea
aria-label="Message"
placeholder="Ask the agent..."
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
<InputGroupAddon align="block-end">
<AiContextMenu onChoose={setContext} />
<span style={{ flex: 1 }} />
<InputGroupButton
type="submit"
variant={text.trim() ? "default" : "secondary"}
size="icon-sm"
aria-label="Send"
aria-disabled={!text.trim()}
>
<Icon name="ArrowUp" />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
<p
role="status"
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
border: 0,
}}
>
{context ? "Context: " + context : ""}
</p>
</div>
);
} Components and API
This composition has no separate component API. Use the APIs for its installed components.
| Component | Target reference |
|---|---|
message-scroller | React Aria API |
message | React Aria API |
bubble | React Aria API |
input-group | React Aria API |
button | React Aria API |
icon | React Aria API |
empty | React Aria API |
dropdown-menu | React Aria API |
tooltip | React Aria API |