f-ui
Components

Prompt Composer

Contained chat composer with auto-resize textarea and Send/Stop controls.

Plus Registry

This component ships from registry.plus.json, not the public registry.json. Set up @f-ui-plus on the Installation page, then install @f-ui-plus/prompt-composer.

Prompt Composer is the contained chat input bar — auto-resizing draft field, Send when idle, Stop when loading. Compose pending files with Attachment above the textarea, and put tools + Submit in PromptComposerActions. It does not own the chat client. Place it as a sibling of Message Scroller under the same Provider and call scrollToEnd() after a successful send.

When To Use

  • Chat or agent UIs that need Enter-to-send / Shift+Enter newline.
  • Toggle between Send and Stop from a host isLoading flag.
  • Contained rounded chrome (not only a full-bleed border-t strip).
  • Prefer a plain textarea when you do not need Send/Stop chrome or i18n labels.

Interactions

EventBehavior
Enter (no Shift)Submit trimmed non-empty text
Shift+EnterInsert newline
IME composingEnter does not submit
SendCalls onSubmit(text); uncontrolled mode clears draft on success; draft stays focused by default
StopCalls onStop; if omitted while loading, Stop stays visible and disabled
Empty draftSend disabled
LoadingSubmit becomes Stop; textarea stays enabled (not disabled) so focus is not dropped
TooltipTrigger asChild on SubmitWrapper onClick runs first; Send / Stop handler still runs unless the wrapper calls preventDefault

Installing

Configure @f-ui-plus and FUI_PLUS_REGISTRY_TOKEN as in Installation — Plus Registry.

FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/prompt-composer
FUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/prompt-composer
FUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/prompt-composer
FUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/prompt-composer

registryDependencies: button, fui-i18n. No extra runtime packages. Labels (Send / Stop / placeholder) use built-in bundles via I18nProvider.

Usage

import {
  PromptComposer,
  PromptComposerActions,
  PromptComposerSubmit,
  PromptComposerTextarea,
} from "@/components/f-ui/prompt-composer/prompt-composer";

<PromptComposer
  isLoading={isLoading}
  onSubmit={send}
  onStop={stop}
>
  <PromptComposerTextarea />
  <PromptComposerActions className="w-full justify-between">
    {/* host tools — e.g. DropdownMenu (+) */}
    <PromptComposerSubmit>
      {isLoading ? <SquareIcon /> : <ArrowUpIcon />}
    </PromptComposerSubmit>
  </PromptComposerActions>
</PromptComposer>

Icon-only Submit always keeps an aria-label (Send / Stop) from i18n. For visible chrome, wrap PromptComposerSubmit in shadcn Tooltip with TooltipTrigger asChild — the kit composes the trigger’s onClick with Send / Stop so Stop still calls onStop (see Edge Cases & Errors). onSubmit stays (text: string) — close over attachment state in the host.

<Tooltip>
  <TooltipTrigger asChild>
    <PromptComposerSubmit>
      {isLoading ? <SquareIcon /> : <ArrowUpIcon />}
    </PromptComposerSubmit>
  </TooltipTrigger>
  <TooltipContent>{isLoading ? "Stop" : "Send"}</TooltipContent>
</Tooltip>

Examples

Attachments and Icon Submit

Attach files pushes a fake uploading card into Attachment AttachmentGroup, then settles to done. The actions row holds a + Dropdown recipe and an icon Send / Stop control inside TooltipTrigger asChild. While the fake stream runs, click Stop — onStop fires and the button returns to Send.

Enter submits; Shift+Enter inserts a newline.

"use client";

import { useState } from "react";
import { ArrowUpIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react";

import {
  Attachment,
  AttachmentAction,
  AttachmentActions,
  AttachmentContent,
  AttachmentDescription,
  AttachmentGroup,
  AttachmentMedia,
  AttachmentTitle,
  type AttachmentState,
} from "@/components/f-ui/attachment/attachment";
import { FileTypeIcon } from "@/components/f-ui/file-type-icon/file-type-icon";
import {
  PromptComposer,
  PromptComposerActions,
  PromptComposerSubmit,
  PromptComposerTextarea,
} from "@/components/f-ui/prompt-composer/prompt-composer";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";

type DemoAttachment = {
  id: string;
  fileName: string;
  description: string;
  state: AttachmentState;
};

export function PromptComposerDemo() {
  const [isLoading, setIsLoading] = useState(false);
  const [lastSent, setLastSent] = useState<string | null>(null);
  const [attachments, setAttachments] = useState<DemoAttachment[]>([]);

  function addFakeAttachment() {
    const id = `att-${Date.now()}`;
    const fileName = "notes.pdf";
    setAttachments((prev) => [
      ...prev,
      {
        id,
        fileName,
        description: "PDF · uploading…",
        state: "uploading",
      },
    ]);
    window.setTimeout(() => {
      setAttachments((prev) =>
        prev.map((item) =>
          item.id === id
            ? { ...item, state: "done", description: "PDF · 48 KB" }
            : item,
        ),
      );
    }, 1200);
  }

  function removeAttachment(id: string) {
    setAttachments((prev) => prev.filter((item) => item.id !== id));
  }

  return (
    <TooltipProvider>
      <div className="mx-auto flex w-full max-w-lg flex-col gap-3">
        <PromptComposer
          isLoading={isLoading}
          onSubmit={(text) => {
            setLastSent(text);
            setIsLoading(true);
            window.setTimeout(() => setIsLoading(false), 1800);
          }}
          onStop={() => setIsLoading(false)}
        >
          {attachments.length > 0 ? (
            <AttachmentGroup aria-label="Pending attachments" className="pb-1">
              {attachments.map((item) => (
                <Attachment
                  key={item.id}
                  state={item.state}
                  size="sm"
                  className="w-56"
                >
                  <AttachmentMedia variant="icon">
                    <FileTypeIcon fileName={item.fileName} />
                  </AttachmentMedia>
                  <AttachmentContent>
                    <AttachmentTitle>{item.fileName}</AttachmentTitle>
                    <AttachmentDescription>
                      {item.description}
                    </AttachmentDescription>
                  </AttachmentContent>
                  <AttachmentActions>
                    <Tooltip>
                      <TooltipTrigger asChild>
                        <AttachmentAction
                          type="button"
                          aria-label={`Remove ${item.fileName}`}
                          onClick={() => removeAttachment(item.id)}
                        >
                          <XIcon />
                        </AttachmentAction>
                      </TooltipTrigger>
                      <TooltipContent>Remove</TooltipContent>
                    </Tooltip>
                  </AttachmentActions>
                </Attachment>
              ))}
            </AttachmentGroup>
          ) : null}
          <PromptComposerTextarea />
          <PromptComposerActions className="w-full justify-between">
            <DropdownMenu>
              <Tooltip>
                <TooltipTrigger asChild>
                  <DropdownMenuTrigger asChild>
                    <Button
                      type="button"
                      size="icon-sm"
                      variant="outline"
                      aria-label="Add"
                    >
                      <PlusIcon />
                    </Button>
                  </DropdownMenuTrigger>
                </TooltipTrigger>
                <TooltipContent>Add</TooltipContent>
              </Tooltip>
              <DropdownMenuContent align="start" side="top">
                <DropdownMenuItem onSelect={addFakeAttachment}>
                  Attach files
                </DropdownMenuItem>
                <DropdownMenuItem disabled>Create image</DropdownMenuItem>
                <DropdownMenuItem disabled>Web search</DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
            <Tooltip>
              <TooltipTrigger asChild>
                <PromptComposerSubmit>
                  {isLoading ? <SquareIcon /> : <ArrowUpIcon />}
                </PromptComposerSubmit>
              </TooltipTrigger>
              <TooltipContent>{isLoading ? "Stop" : "Send"}</TooltipContent>
            </Tooltip>
          </PromptComposerActions>
        </PromptComposer>
        <p className="text-muted-foreground text-xs">
          {isLoading
            ? "Generating… Stop cancels the fake stream."
            : lastSent
              ? `Last sent: ${lastSent}`
              : "Enter submits; Shift+Enter inserts a newline."}
        </p>
      </div>
    </TooltipProvider>
  );
}

Edge Cases & Errors

Tooltip and asChild Wrappers

Chat apps almost always wrap icon-only PromptComposerSubmit in TooltipTrigger asChild so Send / Stop keeps a visible label. asChild merges props onto the child — including an onClick — which can replace the kit’s Send / Stop handler if you spread props naïvely.

PromptComposerSubmit composes handlers for you:

  1. Run any onClick passed from the wrapper (Tooltip, custom trigger).
  2. Unless that handler calls event.preventDefault(), run the internal Send or Stop handler (submit / onStop).
PatternResult
TooltipTrigger asChildPromptComposerSubmitRecommended — Tooltip + Send / Stop both work
onClick only on a plain Button beside the composerFine — use a separate tool button, not PromptComposerSubmit
onClick on PromptComposerSubmit that calls preventDefaultBlocks Send / Stop — use only when you intentionally override
Headless: spread submitProps then {...props} on your own buttonprops.onClick overwrites Stop — call submitProps.onClick?.() after the wrapper handler (zero-arg; same as host onStop)

disabled on PromptComposerSubmit merges with the kit gate: host disabled (e.g. block Send while attachments upload) OR internal Send-disabled / Stop-without-onStop.

Do not put type="submit" on a wrapper that bypasses PromptComposerSubmit while loading — Stop must stay type="button" with onStop wired on the composer.

Headless Usage

usePromptComposer is the view-model: draft value, textareaProps, formProps, and submitProps (mode send/stop). Spread onto native elements when you own the chrome.

canSubmit: false · mode: send

status: idle

"use client";

import { useState } from "react";

import { usePromptComposer } from "@/components/f-ui/prompt-composer/use-prompt-composer";

export function PromptComposerHeadlessDemo() {
  const [isLoading, setIsLoading] = useState(false);
  const [log, setLog] = useState<string>("idle");
  const composer = usePromptComposer({
    isLoading,
    onSubmit: (text) => {
      setLog(`submitted: ${text}`);
      setIsLoading(true);
      window.setTimeout(() => {
        setIsLoading(false);
        setLog("complete");
      }, 1200);
    },
    onStop: () => {
      setIsLoading(false);
      setLog("stopped");
    },
  });

  return (
    <form {...composer.formProps}>
      <label>
        Draft
        <br />
        <textarea {...composer.textareaProps} />
      </label>
      <p>
        canSubmit: {String(composer.canSubmit)} · mode:{" "}
        {composer.submitProps.mode}
      </p>
      <button
        type={composer.submitProps.type}
        disabled={composer.submitProps.disabled}
        aria-label={composer.submitProps["aria-label"]}
        onClick={composer.submitProps.onClick}
      >
        {composer.submitProps.mode === "stop" ? "Stop" : "Send"}
      </button>
      <p>status: {log}</p>
    </form>
  );
}

Composition

PromptComposer
├── AttachmentGroup?              (host pending attachments)
├── PromptComposerTextarea
└── PromptComposerActions
    ├── start: host tools         (e.g. DropdownMenu +)
    └── end: PromptComposerSubmit (text or icon Send / Stop)

API Reference

Props

PropTypeDefaultDescription
value / onValueChangestring / (v) => voidControlled draft.
defaultValuestring""Uncontrolled initial draft.
isLoadingbooleanfalseWhen true, primary control is Stop; empty submit blocked. Does not disable the textarea.
onSubmit(text: string) => void | Promise<void>Trimmed non-empty text.
onStop() => voidStop generation; omit → Stop visible and disabled.
maxHeightnumber240Cap auto-resize height in px.
focusOnSubmitbooleantrueRefocus the draft after Send / Enter so the user can keep typing.
classNamestringMerged onto the form chrome.
childrenReactNodeTextarea, actions, submit parts.

PromptComposerSubmit accepts optional children. With children, size defaults to icon; without children, it renders the i18n Send / Stop label. aria-label is always set to that label.

PropTypeDescription
classNamestringMerged onto the button.
childrenReactNodeIcon or text; omit for default i18n label.
onClickMouseEventHandlerComposed before Send / Stop — use for asChild wrappers; do not replace the kit handler unless you preventDefault.
disabledbooleanMerged with internal Send-disabled / Stop-without-onStop (disabled ?? submitDisabled).
Other button attrsPassed through except type and aria-label (owned by the kit).

Slots

Parts: PromptComposer, PromptComposerTextarea, PromptComposerActions, PromptComposerSubmit.

Hook

usePromptComposer(options) returns { value, setValue, isLoading, canSubmit, textareaProps, formProps, submitProps }. Use under your own form markup, or let the container wire parts via context.

On this page