Meet Frontfriend.Your brand. Your rules. Your agents, connected.Explore what’s new

Model Selector

A model and reasoning selector that groups available options into accessible menus.

Interactive preview

The shared preview uses React Base. The source below uses React Aria. Each example runs with local data.

Installation

Complete project setup with react/aria, then install these components:

pnpm exec frontfriend download dropdown-menu input-group icon attachment -f react --primitive aria

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 {
  DropdownMenuTrigger,
  DropdownMenu,
  DropdownMenuSeparator,
  DropdownMenuGroup,
  DropdownMenuLabel,
  DropdownMenuItem,
} 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 }}>
      <DropdownMenuTrigger>
        <InputGroupButton
          size="sm"
          variant="outline"
          aria-label={"Model: " + model}
        >
          {model}
          <Icon name="ChevronDown" />
        </InputGroupButton>
        <DropdownMenu
          aria-label="Models"
          selectionMode="single"
          disallowEmptySelection
          selectedKeys={[model]}
          onSelectionChange={(keys) => {
            if (keys !== "all") setModel(String([...keys][0]));
          }}
        >
          {[
            { 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) => (
                  <DropdownMenuItem key={value} id={value}>
                    {value}
                  </DropdownMenuItem>
                ))}
              </DropdownMenuGroup>
            </React.Fragment>
          ))}
        </DropdownMenu>
      </DropdownMenuTrigger>
      <DropdownMenuTrigger>
        <InputGroupButton
          size="sm"
          variant="outline"
          aria-label={"Effort: " + effort}
        >
          {effort}
          <Icon name="ChevronDown" />
        </InputGroupButton>
        <DropdownMenu
          aria-label="Reasoning effort"
          selectionMode="single"
          disallowEmptySelection
          selectedKeys={[effort]}
          onSelectionChange={(keys) => {
            if (keys !== "all") setEffort(String([...keys][0]));
          }}
        >
          <DropdownMenuGroup>
            <DropdownMenuLabel>Reasoning effort</DropdownMenuLabel>
            {["Low", "Medium", "High"].map((value) => (
              <DropdownMenuItem key={value} id={value}>
                {value}
              </DropdownMenuItem>
            ))}
          </DropdownMenuGroup>
        </DropdownMenu>
      </DropdownMenuTrigger>
    </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}
                        onPress={() => {
                          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"
                onPress={() => 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.

ComponentTarget reference
dropdown-menu React Aria API
input-group React Aria API
icon React Aria API
attachment React Aria API