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

Installation

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

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

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}>
                      {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 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 Radix UI API
input-group Radix UI API
icon Radix UI API
attachment Radix UI API