Installation
Complete project setup with react/radix, then install these components:
pnpm exec frontfriend download input-group attachment icon dropdown-menu -f react --primitive radix Enter sends and Shift+Enter adds a line. Input composition does not submit. Empty prompts are disabled unless a file is attached. The file input resets after selection so the same file can be selected again. Files remain local; this example does not upload them.
Usage
Complete example
import {
InputGroup,
InputGroupAddon,
InputGroupTextarea,
InputGroupButton,
} from "@/components/ui/input-group";
import {
Attachment,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentActions,
AttachmentAction,
} from "@/components/ui/attachment";
import { Icon } from "@/components/ui/icon";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioItem,
} from "@/components/ui/dropdown-menu";
import * as React from "react";
function AiModelMenus() {
const [model, setModel] = React.useState("Balanced");
const [effort, setEffort] = React.useState("Medium");
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<InputGroupButton
size="sm"
variant="outline"
aria-label={"Model: " + model}
>
{model}
<Icon name="ChevronDown" />
</InputGroupButton>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuRadioGroup value={model} onValueChange={setModel}>
{[
{ name: "Provider A", models: ["Fast", "Balanced"] },
{ name: "Provider B", models: ["Precise"] },
].map((provider, index) => (
<React.Fragment key={provider.name}>
{index > 0 && <DropdownMenuSeparator />}
<DropdownMenuGroup>
<DropdownMenuLabel>{provider.name}</DropdownMenuLabel>
{provider.models.map((value) => (
<DropdownMenuRadioItem key={value} value={value}>
{value}
</DropdownMenuRadioItem>
))}
</DropdownMenuGroup>
</React.Fragment>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<InputGroupButton
size="sm"
variant="outline"
aria-label={"Effort: " + effort}
>
{effort}
<Icon name="ChevronDown" />
</InputGroupButton>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuLabel>Reasoning effort</DropdownMenuLabel>
<DropdownMenuRadioGroup value={effort} onValueChange={setEffort}>
{["Low", "Medium", "High"].map((value) => (
<DropdownMenuRadioItem key={value} value={value}>
{value}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export default function AiPromptInputExample() {
const [text, setText] = React.useState("");
const [files, setFiles] = React.useState<
Array<{
id: string;
name: string;
file: File;
}>
>([]);
const [status, setStatus] = React.useState("");
const formRef = React.useRef<HTMLFormElement>(null);
const fileInput = React.useRef<HTMLInputElement>(null);
function send(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!text.trim() && !files.length) return;
setStatus(
`Sent: ${text.trim() || "Files only"} (${files.length} ${files.length === 1 ? "file" : "files"}).`,
);
setText("");
setFiles([]);
}
return (
<div
style={{
width: "100%",
maxWidth: 560,
minWidth: 0,
marginInline: "auto",
display: "flex",
flexDirection: "column",
gap: 16,
paddingBlock: 16,
fontSize: "var(--text-sm)",
lineHeight: 1.5,
}}
>
<form ref={formRef} onSubmit={send}>
<input
ref={fileInput}
type="file"
multiple
hidden
aria-label="Attach files"
onChange={(event) => {
const added = Array.from(event.target.files ?? []).map((file) => ({
id: crypto.randomUUID(),
name: file.name,
file,
}));
setFiles((previous) => [...previous, ...added]);
event.target.value = "";
}}
/>
<InputGroup>
{files.length > 0 && (
<InputGroupAddon align="block-start">
<div
style={{
display: "flex",
gap: 8,
flexWrap: "wrap",
minWidth: 0,
width: "100%",
}}
>
{files.map((file) => (
<Attachment key={file.id} size="sm">
<AttachmentMedia>
<Icon name="FileText" />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>{file.name}</AttachmentTitle>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction
type="button"
aria-label={"Remove " + file.name}
onClick={() => {
setFiles((previous) =>
previous.filter((item) => item.id !== file.id),
);
formRef.current?.querySelector("textarea")?.focus();
}}
>
<Icon name="X" />
</AttachmentAction>
</AttachmentActions>
</Attachment>
))}
</div>
</InputGroupAddon>
)}
<InputGroupTextarea
value={text}
onChange={(event) => setText(event.target.value)}
aria-label="Prompt"
placeholder="Ask the agent to change your code"
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
<InputGroupAddon align="block-end">
<div
style={{
display: "flex",
gap: 8,
flexWrap: "wrap",
width: "100%",
alignItems: "center",
}}
>
<InputGroupButton
type="button"
variant="outline"
size="icon-sm"
aria-label="Attach files"
onClick={() => fileInput.current?.click()}
>
<Icon name="Plus" />
</InputGroupButton>
<div style={{ marginInlineStart: "auto" }}>
<AiModelMenus />
</div>
<InputGroupButton
type="submit"
variant={text.trim() || files.length ? "default" : "secondary"}
size="icon-sm"
aria-label="Send"
aria-disabled={!text.trim() && !files.length}
>
<Icon name="ArrowUp" />
</InputGroupButton>
</div>
</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,
}}
>
{status}
</p>
</div>
);
} Components and API
This composition has no separate component API. Use the APIs for its installed components.
| Component | Target reference |
|---|---|
input-group | Radix UI API |
attachment | Radix UI API |
icon | Radix UI API |
dropdown-menu | Radix UI API |