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

Installation

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

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

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

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 AiPromptInputExample() {
  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="Ask the agent to change your code"
            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
input-group Base UI API
attachment Base UI API
icon Base UI API
dropdown-menu Base UI API