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
isLoadingflag. - Contained rounded chrome (not only a full-bleed
border-tstrip). - Prefer a plain textarea when you do not need Send/Stop chrome or i18n labels.
Interactions
| Event | Behavior |
|---|---|
| Enter (no Shift) | Submit trimmed non-empty text |
| Shift+Enter | Insert newline |
| IME composing | Enter does not submit |
| Send | Calls onSubmit(text); uncontrolled mode clears draft on success; draft stays focused by default |
| Stop | Calls onStop; if omitted while loading, Stop stays visible and disabled |
| Empty draft | Send disabled |
| Loading | Submit becomes Stop; textarea stays enabled (not disabled) so focus is not dropped |
TooltipTrigger asChild on Submit | Wrapper 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-composerFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/prompt-composerFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/prompt-composerFUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/prompt-composerregistryDependencies: 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:
- Run any
onClickpassed from the wrapper (Tooltip, custom trigger). - Unless that handler calls
event.preventDefault(), run the internal Send or Stop handler (submit/onStop).
| Pattern | Result |
|---|---|
TooltipTrigger asChild → PromptComposerSubmit | Recommended — Tooltip + Send / Stop both work |
onClick only on a plain Button beside the composer | Fine — use a separate tool button, not PromptComposerSubmit |
onClick on PromptComposerSubmit that calls preventDefault | Blocks Send / Stop — use only when you intentionally override |
Headless: spread submitProps then {...props} on your own button | props.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.
"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
| Prop | Type | Default | Description |
|---|---|---|---|
value / onValueChange | string / (v) => void | — | Controlled draft. |
defaultValue | string | "" | Uncontrolled initial draft. |
isLoading | boolean | false | When true, primary control is Stop; empty submit blocked. Does not disable the textarea. |
onSubmit | (text: string) => void | Promise<void> | — | Trimmed non-empty text. |
onStop | () => void | — | Stop generation; omit → Stop visible and disabled. |
maxHeight | number | 240 | Cap auto-resize height in px. |
focusOnSubmit | boolean | true | Refocus the draft after Send / Enter so the user can keep typing. |
className | string | — | Merged onto the form chrome. |
children | ReactNode | — | Textarea, 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.
| Prop | Type | Description |
|---|---|---|
className | string | Merged onto the button. |
children | ReactNode | Icon or text; omit for default i18n label. |
onClick | MouseEventHandler | Composed before Send / Stop — use for asChild wrappers; do not replace the kit handler unless you preventDefault. |
disabled | boolean | Merged with internal Send-disabled / Stop-without-onStop (disabled ?? submitDisabled). |
| Other button attrs | — | Passed 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.