Skip to main content
Gremorie

PromptInput

The cornerstone compose surface for any AI chat. A form-rooted state machine with auto-resizing textarea, attachment chips, action menu, speech input, model selector and a status-driven submit button.

Overview

PromptInput is the largest primitive in @gremorie/rx-ai. It is a <form>-rooted state machine that composes an InputGroup from @gremorie/rx-forms (textarea + addons rendered as one cohesive surface) with the full set of chat-input affordances: file attachments with hover-card previews, an action menu, a speech-to-text button, a model selector, in-input action buttons and a status-driven submit button whose icon flips between arrow / spinner / stop / X depending on chat state.

State is managed in three layers:

  1. Self-managed (default). Everything lives inside PromptInput. Pass onSubmit and you are done.
  2. Provider-lifted via PromptInputProvider. Expose usePromptInputController() to read or mutate the text and attachments from anywhere in the tree.
  3. Controlled textarea. The provider's textInput.value / textInput.setInput drive PromptInputTextarea automatically when the provider is present.

The component also carries an enforced keyboard contract: Enter submits, Shift+Enter newlines, Backspace on an empty textarea removes the last attachment, paste-with-files attaches automatically, IME composition events are honored. Submit is wired through form.requestSubmit() so HTML form validation still applies.

Installation

bash npx gremorie@latest add rx-prompt-input

bash pnpm dlx gremorie@latest add rx-prompt-input

bash yarn dlx gremorie@latest add rx-prompt-input

bash bunx --bun gremorie@latest add rx-prompt-input

Brings in rx-forms (InputGroup family + Button + Select), rx-overlays (DropdownMenu + HoverCard + Tooltip), rx-display (Command + Dialog) and rx-core (the cn helper) as registry dependencies.

Usage

Minimal

"use client";

import { useState } from "react";
import {
  type ChatStatus,
  PromptInput,
  PromptInputBody,
  PromptInputFooter,
  PromptInputSubmit,
  PromptInputTextarea,
} from "@gremorie/rx-ai";

export function ChatBox() {
  const [status, setStatus] = useState<ChatStatus>("ready");

return (

<PromptInput onSubmit={(message) => send(message.text, message.files)}>
<PromptInputBody>
<PromptInputTextarea placeholder="Ask anything" />
<PromptInputFooter>
<span />
<PromptInputSubmit status={status} />
</PromptInputFooter>
</PromptInputBody>
</PromptInput>
);
}
import {
  PromptInput,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputToolbar,
} from "@gremorie/ng-ai";

@Component({
  selector: "app-chat-box",
  standalone: true,
  imports: [PromptInput, PromptInputTextarea, PromptInputToolbar, PromptInputSubmit],
  template: `
    <gremorie-prompt-input (gremoriePromptSubmit)="send($event)">
      <gremorie-prompt-input-textarea placeholder="Ask anything" />
      <gremorie-prompt-input-toolbar>
        <gremorie-prompt-input-submit [status]="status()" />
      </gremorie-prompt-input-toolbar>
    </gremorie-prompt-input>
  `,
})
export class ChatBoxComponent {
  status = signal<ChatStatus>("ready");
}
'use client';

import { useRef, useState } from 'react';
import {
  type ChatStatus,
  PromptInput,
  PromptInputActionAddAttachments,
  PromptInputActionMenu,
  PromptInputActionMenuContent,
  PromptInputActionMenuTrigger,
  PromptInputAttachment,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputButton,
  PromptInputFooter,
  PromptInputSelect,
  PromptInputSelectContent,
  PromptInputSelectItem,
  PromptInputSelectTrigger,
  PromptInputSelectValue,
  PromptInputSpeechButton,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
} from '@gremorie/rx-ai';
import { GlobeIcon } from 'lucide-react';

const models = [
  { id: 'gpt-4o', label: 'GPT-4o' },
  { id: 'claude-4.5', label: 'Claude 4.5' },
];

export function ChatBox() {
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const [status, setStatus] = useState<ChatStatus>('ready');
  const [model, setModel] = useState('gpt-4o');

  return (
    <PromptInput
      accept="image/*"
      multiple
      maxFileSize={5_000_000}
      onSubmit={(message) => send(message.text, message.files)}
    >
      <PromptInputBody>
        <PromptInputAttachments>
          {(file) => <PromptInputAttachment data={file} />}
        </PromptInputAttachments>
        <PromptInputTextarea ref={textareaRef} placeholder="Ask anything" />
        <PromptInputFooter>
          <PromptInputTools>
            <PromptInputActionMenu>
              <PromptInputActionMenuTrigger aria-label="Add attachment" />
              <PromptInputActionMenuContent>
                <PromptInputActionAddAttachments />
              </PromptInputActionMenuContent>
            </PromptInputActionMenu>
            <PromptInputSpeechButton textareaRef={textareaRef} />
            <PromptInputButton aria-label="Search the web">
              <GlobeIcon />
              Web
            </PromptInputButton>
            <PromptInputSelect value={model} onValueChange={setModel}>
              <PromptInputSelectTrigger>
                <PromptInputSelectValue placeholder="Model" />
              </PromptInputSelectTrigger>
              <PromptInputSelectContent>
                {models.map((m) => (
                  <PromptInputSelectItem key={m.id} value={m.id}>
                    {m.label}
                  </PromptInputSelectItem>
                ))}
              </PromptInputSelectContent>
            </PromptInputSelect>
          </PromptInputTools>
          <PromptInputSubmit status={status} />
        </PromptInputFooter>
      </PromptInputBody>
    </PromptInput>
  );
}

API

Types

TypeShapeDescription
ChatStatus"ready" | "submitted" | "streaming" | "error"The lifecycle states PromptInputSubmit reacts to.
FileUIPart{ type: "file"; url: string; mediaType: string; filename?: string }The Vercel AI SDK's file part shape. Exported here for convenience.
AttachmentFileFileUIPart & { id: string }Internal representation that adds a stable ID. Used by the attachments context.
PromptInputMessage{ text: string; files: FileUIPart[] }What onSubmit receives.

<PromptInput>

The root form. Manages attachments, file drag-drop and submit lifecycle. Use a PromptInputProvider parent to lift state outside.

PropTypeDefaultDescription
onSubmit(message: PromptInputMessage, event: FormEvent) => void | Promise<void>-Required. Fires on form submit (Enter key, Submit click, or requestSubmit).
acceptstring-Comma-separated MIME pattern ("image/*,application/pdf"). Validates pasted and dropped files.
multipleboolean-Allow more than one file per pick.
globalDropbooleanfalseWhen true, accepts drops anywhere on the document. Default scope is the form element.
syncHiddenInputbooleanfalseClear the hidden file input when the attachments list goes to 0. Useful for re-picking the same filename.
maxFilesnumber-Cap on total attachments. Extra files trigger onError({ code: "max_files" }).
maxFileSizenumber-Per-file byte cap. Files over the limit trigger onError({ code: "max_file_size" }).
onError(err: { code: "max_files" | "max_file_size" | "accept"; message: string }) => void-Fires when validation rejects files.
classNamestring-Extra classes on the underlying InputGroup.
childrenReactNode-Typically PromptInputBody and its descendants.

<PromptInputProvider>

Optional provider that lifts text and attachment state above PromptInput. Wrap your tree once near the root, then any descendant can use usePromptInputController() or useProviderAttachments().

PropTypeDefaultDescription
initialInputstring""Seed value for the controlled textarea.
childrenReactNode-The tree that should observe the lifted state.

<PromptInputBody> / <PromptInputHeader> / <PromptInputFooter> / <PromptInputTools>

Layout primitives.

  • PromptInputBody wraps children in display: contents so they flow directly into the InputGroup.
  • PromptInputHeader is an InputGroupAddon pinned to align="block-end" with order-first flex-wrap gap-1. Use for header-style toolbars (above the textarea).
  • PromptInputFooter is an InputGroupAddon pinned to align="block-end" with justify-between gap-1. Default home for tools + submit.
  • PromptInputTools is a flex items-center gap-1 row meant to live inside the footer.

All extend their underlying DOM element props.

<PromptInputTextarea>

The composer. Auto-resizes between min-h-16 and max-h-48. Default name="message" so it serializes as the form's text field.

PropTypeDefaultDescription
placeholderstring"What would you like to know?"Placeholder text.
value / onChangecontrolled props-When provided, the textarea is fully controlled. Otherwise the provider's text input drives it (if any).

Built-in keyboard contract:

KeyBehavior
EnterrequestSubmit() (skipped if Submit is disabled)
Shift+EnterNewline
Backspace on empty textareaRemoves the most recent attachment
IME compositionEnter is ignored while composing
Paste with filesAttaches files automatically

<PromptInputAttachments> / <PromptInputAttachment>

ComponentPurpose
PromptInputAttachmentsContainer that renders one child per attachment via a function child. Renders nothing when the list is empty.
PromptInputAttachmentA single chip with a hover-card preview (image thumbnail or paperclip icon) and a remove button.

PromptInputAttachments:

PropTypeDefaultDescription
children(attachment: AttachmentFile) => ReactNode-Required. Render function.
classNamestring-Extra classes on the wrapper.

PromptInputAttachment:

PropTypeDefaultDescription
dataAttachmentFile-Required. The attachment to render.
classNamestring-Extra classes on the chip.

<PromptInputButton> / <PromptInputSubmit>

PromptInputButton wraps InputGroupButton. Picks size="icon-sm" for single-child icons and size="sm" for icon+label, unless you set size explicitly. Default variant="ghost".

PromptInputSubmit is a status-driven InputGroupButton whose icon depends on status:

statusIconBehavior
"ready"CornerDownLeftIconSubmit when clicked. Disabled when the textarea is empty.
"submitted"Loader2Icon (spinning)Disabled.
"streaming"SquareIconClick to stop generation (host wires up the handler).
"error"XIconClick to retry (host wires up the handler).
undefinedCornerDownLeftIconSame as "ready".

Carries aria-label="Submit".

Action menu family

PromptInputActionMenu, PromptInputActionMenuTrigger, PromptInputActionMenuContent and PromptInputActionMenuItem are thin wrappers around DropdownMenu, DropdownMenuTrigger, DropdownMenuContent and DropdownMenuItem. The trigger defaults to a PromptInputButton with a PlusIcon.

PromptInputActionAddAttachments is a pre-wired DropdownMenuItem that calls attachments.openFileDialog() on select. Default label: "Add photos or files". Accepts a label prop to override.

<PromptInputSpeechButton>

Web Speech API toggle.

PropTypeDefaultDescription
textareaRefRefObject<HTMLTextAreaElement | null>-When provided, transcribed text is appended to the textarea value.
onTranscriptionChange(text: string) => void-Fires whenever a final transcript is appended.
langstring"en-US"BCP-47 language tag passed to SpeechRecognition.

Carries aria-label="Toggle voice input". Disabled when neither SpeechRecognition nor webkitSpeechRecognition is available. Animates a pulsing accent surface while listening.

<PromptInputSelect> family

Wrappers around Select from @gremorie/rx-forms (PromptInputSelect, PromptInputSelectTrigger, PromptInputSelectContent, PromptInputSelectItem, PromptInputSelectValue). The trigger is restyled to look inline inside the input row (borderless, transparent background, muted foreground).

<PromptInputHoverCard> family

PromptInputHoverCard, PromptInputHoverCardTrigger, PromptInputHoverCardContent. HoverCard from @gremorie/rx-overlays with openDelay and closeDelay defaulted to 0 so previews feel instant. Content defaults to align="start". Used internally by PromptInputAttachment.

<PromptInputCommand> family

PromptInputCommand, PromptInputCommandInput, PromptInputCommandList, PromptInputCommandEmpty, PromptInputCommandGroup, PromptInputCommandItem, PromptInputCommandSeparator. Thin wrappers around the Command palette primitives.

PromptInputCommandDialog is the Cmd+K floating palette - same Command palette inside a Dialog for global launcher use.

<PromptInputTabs*> family

PromptInputTabsList, PromptInputTab, PromptInputTabLabel, PromptInputTabBody, PromptInputTabItem. Lightweight non-stateful tabbed panels for use inside menus and dropdowns. They are styled containers - bring your own selection state.

Hooks

HookPurpose
usePromptInputController()Read or mutate the text input value and attachments. Throws if no PromptInputProvider parent.
useProviderAttachments()Read attachment state lifted to the provider. Throws if no PromptInputProvider parent.
usePromptInputAttachments()Read attachment state from the closest PromptInput. Used inside descendants like PromptInputTextarea and PromptInputAttachment.

Composition

PromptInput (root <form>, state machine + drag-drop + InputGroup wrapper)
  PromptInputBody (display: contents, flows children into the group)
    PromptInputHeader (optional, above the textarea)
    PromptInputAttachments (render-prop list of chips, hidden when empty)
      PromptInputAttachment (chip + HoverCard preview + remove button)
    PromptInputTextarea (auto-resize, Enter submits, Shift+Enter newline)
    PromptInputFooter (default home for tools + submit)
      PromptInputTools (left, action buttons)
        PromptInputActionMenu (dropdown trigger + content + items)
          PromptInputActionAddAttachments (pre-wired item)
        PromptInputSpeechButton (Web Speech toggle)
        PromptInputButton (custom actions - web search, etc.)
        PromptInputSelect (model picker)
      PromptInputSubmit (right, status-driven icon)

How the parts plug together

  1. InputGroup wrapping: The React root wraps <form> content in an InputGroup from @gremorie/rx-forms. That gives you a single rounded surface with shared focus, invalid and disabled states across the textarea and every addon.
  2. Block-aligned addons: PromptInputHeader and PromptInputFooter are both InputGroupAddon align="block-end". They render below the textarea visually but participate in the group's focus ring.
  3. Sizing: Every toolbar button picks xs, sm, icon-xs or icon-sm to match the group height automatically. PromptInputButton picks icon-sm when it has a single child and sm when it has icon+label, unless you override.
  4. Attachment state context: PromptInput (or PromptInputProvider when present) provides the attachments context that PromptInputAttachments, PromptInputAttachment and PromptInputActionAddAttachments all read. Adding via the action menu, paste, drag-drop or attachments.add(files) all funnel into the same list.

Variations

Lifted state via Provider

Use PromptInputProvider to share the textarea value and attachments with siblings (e.g. a suggestions panel that wants to write into the input).

import { PromptInputProvider, usePromptInputController } from '@gremorie/rx-ai';

function SuggestionRow() {
  const { textInput } = usePromptInputController();
  return (
    <Button onClick={() => textInput.setInput('Summarize this thread')}>
      Insert suggestion
    </Button>
  );
}

export function ChatSurface() {
  return (
    <PromptInputProvider initialInput="">
      <SuggestionRow />
      <PromptInput onSubmit={handle}>
        <PromptInputBody>
          <PromptInputTextarea />
          <PromptInputFooter>
            <span />
            <PromptInputSubmit />
          </PromptInputFooter>
        </PromptInputBody>
      </PromptInput>
    </PromptInputProvider>
  );
}

Constrained image attachments

Reject everything except images and cap at 5 MB.

<PromptInput
  accept="image/*"
  multiple
  maxFiles={4}
  maxFileSize={5_000_000}
  onError={(err) => toast.error(err.message)}
  onSubmit={handle}
>
  {/* ... */}
</PromptInput>

Global drop zone

Accept files dropped anywhere on the page.

<PromptInput globalDrop accept="image/*" onSubmit={handle}>
  {/* ... */}
</PromptInput>

With Cmd+K command palette

Pair the inline composer with a global launcher using PromptInputCommandDialog.

const [open, setOpen] = useState(false);

useEffect(() => {
  const handler = (e: KeyboardEvent) => {
    if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
      e.preventDefault();
      setOpen((v) => !v);
    }
  };
  document.addEventListener('keydown', handler);
  return () => document.removeEventListener('keydown', handler);
}, []);

<PromptInputCommandDialog open={open} onOpenChange={setOpen}>
  <PromptInputCommandInput placeholder="Type a command..." />
  <PromptInputCommandList>
    <PromptInputCommandEmpty>No results.</PromptInputCommandEmpty>
    <PromptInputCommandGroup heading="Actions">
      <PromptInputCommandItem>New chat</PromptInputCommandItem>
      <PromptInputCommandItem>Clear history</PromptInputCommandItem>
    </PromptInputCommandGroup>
  </PromptInputCommandList>
</PromptInputCommandDialog>;

Streaming status

Wire status to your network state to flip Submit between arrow, spinner and stop.

const [status, setStatus] = useState<ChatStatus>('ready');

async function send(message: PromptInputMessage) {
  setStatus('submitted');
  try {
    await streamResponse(message, {
      onStreamStart: () => setStatus('streaming'),
      onComplete: () => setStatus('ready'),
    });
  } catch {
    setStatus('error');
  }
}

<PromptInputSubmit
  status={status}
  onClick={status === 'streaming' ? stop : undefined}
/>;

Accessibility

  • Real <form> semantics: PromptInput renders a <form>, so Enter submits via form.requestSubmit(). HTML form validation still runs - native required / pattern attributes work on any field inside.
  • Submit aria-label: PromptInputSubmit always carries aria-label="Submit". If your localization differs, override via your own Button composition.
  • Keyboard contract (documented above and enforced in code): Enter submits, Shift+Enter inserts a newline, Backspace on empty textarea removes the latest attachment. The IME composition flag (onCompositionStart / onCompositionEnd) suppresses Enter while composing CJK input.
  • Speech button degradation: When the Web Speech API is missing, PromptInputSpeechButton is disabled rather than hidden. aria-label="Toggle voice input" stays on the disabled button so screen readers can describe what is unavailable.
  • Attachment remove: Each chip's remove button carries aria-label="Remove attachment" plus an sr-only "Remove" span.
  • Tools needing tooltips: Icon-only PromptInputButtons require an aria-label. There is no built-in tooltip, so wrap each in Tooltip from @gremorie/rx-overlays when you want a hover hint.
  • Status announcement: When status changes, consider announcing the new state via a separate aria-live region in the host app - Submit's icon swap alone is silent for screen readers.
  • Focus visible: The InputGroup surface drives the focus ring. Do not strip outlines.
  • Reduced motion: The speech button's pulse animation and the submit spinner respect prefers-reduced-motion via Tailwind's default motion-reduce: variants (apply manually to custom additions).
  • Message - the assistant / user turn rendered above this composer
  • Conversation - the scrollable surface that hosts both messages and this composer
  • Model Selector - alternative standalone model picker if you do not want it inline
  • Suggestion - pre-baked prompts users can click to seed the textarea
  • Open in Chat - hand the current query off to an external chat

On this page