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

Prompt Input

A prompt composer that combines text entry, file attachments, model controls, and submission.

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 input-group attachment icon dropdown-menu -f vue --primitive reka

Enter sends and Shift+Enter adds a line. Input composition does not submit. Empty prompts are disabled unless a file is attached. The file input resets after selection so the same file can be selected again. Files remain local; this example does not upload them.

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": "Ask the agent to change your code",
};
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
input-group Reka UI API
attachment Reka UI API
icon Reka UI API
dropdown-menu Reka UI API