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

Installation

Complete project setup with vue/reka, then install these components:

pnpm exec frontfriend download dropdown-menu input-group icon attachment -f vue --primitive reka

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

<script setup lang="ts">
import { ref } from "vue";
import { InputGroup } from "@/components/ui/input-group";
import { InputGroupAddon } from "@/components/ui/input-group";
import { Attachment } from "@/components/ui/attachment";
import { AttachmentMedia } from "@/components/ui/attachment";
import { Icon } from "@/components/ui/icon";
import { AttachmentContent } from "@/components/ui/attachment";
import { AttachmentTitle } from "@/components/ui/attachment";
import { AttachmentActions } from "@/components/ui/attachment";
import { AttachmentAction } from "@/components/ui/attachment";
import { InputGroupTextarea } from "@/components/ui/input-group";
import { InputGroupButton } from "@/components/ui/input-group";
import { DropdownMenu } from "@/components/ui/dropdown-menu";
import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { DropdownMenuContent } from "@/components/ui/dropdown-menu";
import { DropdownMenuRadioGroup } from "@/components/ui/dropdown-menu";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { DropdownMenuGroup } from "@/components/ui/dropdown-menu";
import { DropdownMenuLabel } from "@/components/ui/dropdown-menu";
import { DropdownMenuRadioItem } from "@/components/ui/dropdown-menu";

const args = {
  "placeholder": "Describe the change",
};
const focusedMenu = ref("");
const menuFocusStyle = {
  outline: "2px solid var(--border-interactive-mid)",
  outlineOffset: "2px",
};
function focusMenu(event: FocusEvent, name: string) {
  focusedMenu.value =
    event.target instanceof HTMLElement &&
    event.target.matches(":focus-visible")
      ? name
      : "";
}
const text = ref("");
const files = ref<
  Array<{
    id: string;
    name: string;
    file: File;
  }>
>([]);
const formRef = ref<HTMLFormElement | null>(null);
const providers = [
  { name: "Provider A", models: ["Fast", "Balanced"] },
  { name: "Provider B", models: ["Precise"] },
];
const fileInput = ref<HTMLInputElement | null>(null);
const model = ref("Balanced");
const effort = ref("Medium");
const status = ref("");
function selectFiles(event: Event) {
  const input = event.target as HTMLInputElement;
  files.value.push(
    ...Array.from(input.files ?? []).map((file) => ({
      id: crypto.randomUUID(),
      name: file.name,
      file,
    })),
  );
  input.value = "";
}
function send() {
  if (!text.value.trim() && !files.value.length) return;
  status.value = `Sent: ${text.value.trim() || "Files only"} (${files.value.length} ${files.value.length === 1 ? "file" : "files"}).`;
  text.value = "";
  files.value = [];
}
function keydown(event: KeyboardEvent) {
  if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
    event.preventDefault();
    send();
  }
}
function removeFile(id: string) {
  files.value = files.value.filter((file) => file.id !== id);
  formRef.value?.querySelector("textarea")?.focus();
}
</script>

<template>
  <div
    style="
      width: 100%;
      max-width: 560px;
      min-width: 0;
      margin-inline: auto;
      display: flex;
      flex-direction: column;
      gap: 16px;
      padding-block: 16px;
      font-size: var(--text-sm);
      line-height: 1.5;
    "
  >
    <form ref="formRef" @submit.prevent="send">
      <input
        ref="fileInput"
        type="file"
        multiple
        hidden
        aria-label="Attach files"
        @change="selectFiles"
      />
      <InputGroup
        ><InputGroupAddon v-if="files.length" align="block-start"
          ><div
            style="
              display: flex;
              gap: 8px;
              flex-wrap: wrap;
              min-width: 0;
              width: 100%;
            "
          >
            <Attachment v-for="file in files" :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"
                  @click="removeFile(file.id)"
                  ><Icon name="X" /></AttachmentAction></AttachmentActions
            ></Attachment></div></InputGroupAddon
        ><InputGroupTextarea
          v-model="text"
          aria-label="Prompt"
          :placeholder="args.placeholder"
          @keydown="keydown" />
        <InputGroupAddon align="block-end"
          ><div
            style="
              display: flex;
              gap: 8px;
              flex-wrap: wrap;
              width: 100%;
              align-items: center;
            "
          >
            <InputGroupButton
              type="button"
              variant="outline"
              size="icon-sm"
              aria-label="Attach files"
              @click="fileInput?.click()"
              ><Icon name="Plus"
            /></InputGroupButton>
            <div style="margin-inline-start: auto">
              <div style="display: flex; flex-wrap: wrap; gap: 8px">
                <DropdownMenu
                  ><DropdownMenuTrigger as-child
                    ><InputGroupButton
                      size="sm"
                      variant="outline"
                      :style="
                        focusedMenu === 'model' ? menuFocusStyle : undefined
                      "
                      @focus="focusMenu($event, 'model')"
                      @blur="focusedMenu = ''"
                      :aria-label="'Model: ' + model"
                      >{{ model
                      }}<Icon
                        name="ChevronDown" /></InputGroupButton></DropdownMenuTrigger
                  ><DropdownMenuContent
                    ><DropdownMenuRadioGroup v-model="model"
                      ><template
                        v-for="(provider, index) in providers"
                        :key="provider.name"
                        ><DropdownMenuSeparator
                          v-if="index > 0"
                        /><DropdownMenuGroup
                          ><DropdownMenuLabel>{{
                            provider.name
                          }}</DropdownMenuLabel
                          ><DropdownMenuRadioItem
                            v-for="value in provider.models"
                            :key="value"
                            :value="value"
                            >{{ value }}</DropdownMenuRadioItem
                          ></DropdownMenuGroup
                        ></template
                      ></DropdownMenuRadioGroup
                    ></DropdownMenuContent
                  ></DropdownMenu
                >
                <DropdownMenu
                  ><DropdownMenuTrigger as-child
                    ><InputGroupButton
                      size="sm"
                      variant="outline"
                      :style="
                        focusedMenu === 'effort' ? menuFocusStyle : undefined
                      "
                      @focus="focusMenu($event, 'effort')"
                      @blur="focusedMenu = ''"
                      :aria-label="'Effort: ' + effort"
                      >{{ effort
                      }}<Icon
                        name="ChevronDown" /></InputGroupButton></DropdownMenuTrigger
                  ><DropdownMenuContent
                    ><DropdownMenuGroup
                      ><DropdownMenuLabel>Reasoning effort</DropdownMenuLabel
                      ><DropdownMenuRadioGroup v-model="effort"
                        ><DropdownMenuRadioItem
                          v-for="value in ['Low', 'Medium', 'High']"
                          :key="value"
                          :value="value"
                          >{{ value }}</DropdownMenuRadioItem
                        ></DropdownMenuRadioGroup
                      ></DropdownMenuGroup
                    ></DropdownMenuContent
                  ></DropdownMenu
                >
              </div>
            </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: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip-path: inset(50%);
        white-space: nowrap;
        border: 0;
      "
    >
      {{ status }}
    </p>
  </div>
</template>

Components and API

This composition has no separate component API. Use the APIs for its installed components.

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