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 React Aria. Each example runs with local data.

Installation

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

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

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 {
  DropdownMenuTrigger,
  DropdownMenu,
  DropdownMenuSeparator,
  DropdownMenuGroup,
  DropdownMenuLabel,
  DropdownMenuItem,
} 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 }}>
      <DropdownMenuTrigger>
        <InputGroupButton
          size="sm"
          variant="outline"
          aria-label={"Model: " + model}
        >
          {model}
          <Icon name="ChevronDown" />
        </InputGroupButton>
        <DropdownMenu
          aria-label="Models"
          selectionMode="single"
          disallowEmptySelection
          selectedKeys={[model]}
          onSelectionChange={(keys) => {
            if (keys !== "all") setModel(String([...keys][0]));
          }}
        >
          {[
            { 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) => (
                  <DropdownMenuItem key={value} id={value}>
                    {value}
                  </DropdownMenuItem>
                ))}
              </DropdownMenuGroup>
            </React.Fragment>
          ))}
        </DropdownMenu>
      </DropdownMenuTrigger>
      <DropdownMenuTrigger>
        <InputGroupButton
          size="sm"
          variant="outline"
          aria-label={"Effort: " + effort}
        >
          {effort}
          <Icon name="ChevronDown" />
        </InputGroupButton>
        <DropdownMenu
          aria-label="Reasoning effort"
          selectionMode="single"
          disallowEmptySelection
          selectedKeys={[effort]}
          onSelectionChange={(keys) => {
            if (keys !== "all") setEffort(String([...keys][0]));
          }}
        >
          <DropdownMenuGroup>
            <DropdownMenuLabel>Reasoning effort</DropdownMenuLabel>
            {["Low", "Medium", "High"].map((value) => (
              <DropdownMenuItem key={value} id={value}>
                {value}
              </DropdownMenuItem>
            ))}
          </DropdownMenuGroup>
        </DropdownMenu>
      </DropdownMenuTrigger>
    </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}
                        onPress={() => {
                          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"
                onPress={() => 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 React Aria API
attachment React Aria API
icon React Aria API
dropdown-menu React Aria API