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 Base UI. Each example runs with local data.

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.

ComponentTarget reference
dropdown-menu Base UI API
input-group Base UI API
icon Base UI API
attachment Base UI API