Installation
Complete project setup with react/base, then install these components:
pnpm exec frontfriend download dropdown-menu input-group icon attachment -f react --primitive base The selected option updates its trigger label. Use the arrow keys to move through each menu, Enter to select, and Escape to close. Connect the controls to the models and reasoning options supported by your application.
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}
closeOnClick
>
{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} closeOnClick>
{value}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export default function AiModelSelectorExample() {
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="Describe the change"
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 |
|---|---|
dropdown-menu | Base UI API |
input-group | Base UI API |
icon | Base UI API |
attachment | Base UI API |