PromptInputexperimental
An auto-growing textarea for chat input, with a send button that becomes a stop button while a response streams. Enter sends, Shift+Enter inserts a newline, and opt-in attachments accept the paperclip picker, paste, and drag-and-drop.
Playground
Submitted messages appear here. With Busy on, the send button becomes a stop button and Enter no longer submits.
<script lang="ts">
import { PromptInput } from '@urbicon-ui/blocks';
let value = $state('');
const onSubmit = ({ text, attachments }) => console.log(text, attachments);
</script>
<PromptInput
bind:value
{onSubmit}
placeholder="Type a message…"
/>01 Examples
Chat composer with stop
<script lang="ts">
import { PromptInput, SparklesIcon } from '@urbicon-ui/blocks';
type Msg = { id: string; role: 'user' | 'assistant'; text: string };
let draft = $state('');
let busy = $state(false);
let messages = $state<Msg[]>([]);
let timer: ReturnType<typeof setTimeout> | undefined;
function send(text: string) {
messages = [...messages, { id: crypto.randomUUID(), role: 'user', text }];
busy = true;
// Simulate a streaming response; the Send button becomes a Stop button
// while `busy` is true, and Enter no longer submits.
timer = setTimeout(() => {
messages = [
...messages,
{ id: crypto.randomUUID(), role: 'assistant', text: 'Here is a reply to: ' + text }
];
busy = false;
}, 2200);
}
function stop() {
clearTimeout(timer);
busy = false;
messages = [...messages, { id: crypto.randomUUID(), role: 'assistant', text: '(stopped)' }];
}
</script>
<div class="mx-auto flex max-w-xl flex-col gap-3">
{#if messages.length > 0}
<div class="flex flex-col gap-2">
{#each messages as msg (msg.id)}
{#if msg.role === 'user'}
<div
class="bg-primary text-text-on-primary ml-auto max-w-[80%] rounded-2xl px-3 py-2 text-sm"
>
{msg.text}
</div>
{:else}
<div class="text-text-secondary flex max-w-[85%] items-start gap-2 text-sm">
<SparklesIcon class="text-primary mt-0.5 size-4 shrink-0" />
<span>{msg.text}</span>
</div>
{/if}
{/each}
</div>
{/if}
<PromptInput
bind:value={draft}
{busy}
placeholder="Message the assistant…"
onSubmit={({ text }) => send(text)}
onStop={stop}
>
{#snippet hint()}
<span>Enter to send · Shift+Enter for a new line</span>
{/snippet}
</PromptInput>
</div>
Attachments with validation
<script lang="ts">
import { PromptInput, Alert } from '@urbicon-ui/blocks';
import type { FileIntakeEntry, FileIntakeRejection } from '@urbicon-ui/blocks';
let draft = $state('');
let attachments = $state<FileIntakeEntry[]>([]);
let rejections = $state<FileIntakeRejection[]>([]);
let lastSent = $state<string | null>(null);
function handleSubmit(payload: { text: string; attachments: FileIntakeEntry[] }) {
const names = payload.attachments.map((a) => a.file.name);
lastSent =
`"${payload.text}"` +
(names.length ? ` with ${names.length} file(s): ${names.join(', ')}` : ' (no files)');
rejections = [];
}
</script>
<div class="mx-auto flex max-w-xl flex-col gap-3">
{#if rejections.length > 0}
<Alert intent="danger" title="Some files were rejected">
<ul class="list-outside list-disc pl-5 text-sm">
{#each rejections as r (r.file.name)}
<li>{r.file.name} — {r.errors[0]?.message}</li>
{/each}
</ul>
</Alert>
{/if}
{#if lastSent}
<p class="text-text-secondary text-sm">Sent: {lastSent}</p>
{/if}
<PromptInput
bind:value={draft}
bind:attachments
allowAttachments
accept="image/*"
maxFiles={3}
maxFileSize={2 * 1024 * 1024}
placeholder="Add up to 3 images (≤ 2 MB each) and describe them…"
onSubmit={handleSubmit}
onAttachmentReject={(r) => (rejections = r)}
>
{#snippet hint()}
<span>Attach via the paperclip, drag-and-drop, or paste a screenshot.</span>
{/snippet}
</PromptInput>
</div>
Model picker in the trailing zone
<script lang="ts">
import { PromptInput, Select } from '@urbicon-ui/blocks';
let draft = $state('');
let model = $state('sonnet');
let lastSent = $state<string | null>(null);
const models = [
{ label: 'Haiku — fast', value: 'haiku' },
{ label: 'Sonnet — balanced', value: 'sonnet' },
{ label: 'Opus — deep', value: 'opus' }
];
</script>
<div class="mx-auto flex max-w-xl flex-col gap-3">
{#if lastSent}
<p class="text-text-secondary text-sm">Sent to <strong>{model}</strong>: {lastSent}</p>
{/if}
<PromptInput
bind:value={draft}
placeholder="Ask anything…"
onSubmit={({ text }) => (lastSent = text)}
>
{#snippet trailing()}
<Select size="xs" options={models} bind:value={model} aria-label="Model" class="w-40" />
{/snippet}
</PromptInput>
</div>
02 Accessibility
Built-in ARIA
The textarea carries an aria-label (the label prop, default "Message") and aria-keyshortcuts that reflects the active submit gesture: Enter for submitOn="enter", or Meta+Enter (Control+Enter on Windows) for mod-enter. Assistive tech then announces the real
keystroke. The send, stop, and attach buttons each have their own aria-label (sendLabel / stopLabel / attachLabel). Attachment thumbnails are decorative
and hidden from screen readers.
Error status region
The inline error (first attachment rejection) lives in a role="status" region that the textarea references via aria-describedby. It stays sr-only while empty, so the message is announced when it appears and the region never leaves a visual
gap. It clears on the next successful add.
Chip removal & focus
Each attachment chip's remove button is labelled via removeAttachmentLabel(name). Removing a chip moves
focus deterministically — to the chip that shifted into its place, else the last remaining
chip, else back to the textarea when the strip empties — so keyboard users are never dropped
to <body>. Removing a chip through the UI also
revokes its preview object-URL for you.
Keyboard & IME
Enter sends (or inserts a newline under submitOn="mod-enter"); Shift + Enter always inserts a newline. Submission is suppressed mid-IME-composition, so composing Japanese,
Chinese, or Korean text never fires a stray send. Focus rings use focus-visible: for keyboard-only visibility.
03 API Reference
35 propsProp | Type | Default | Description | |
|---|---|---|---|---|
onSubmit required | (payload: { text: string; attachments: FileIntakeEntry[] }) => void | — | Fired on send with the trimmed text and the current attachments. Only fires
when there is text or at least one attachment, and never while busy or
disabled. | |
accept | string | string[] | — | Accepted MIME types / extensions (e.g. 'image/*', ['.pdf', 'image/png']). | |
allowAttachments | boolean | false | Enable the attachment surface: paperclip picker, clipboard-image paste, drag-and-drop, and the chip strip above the textarea. | |
attachLabel | string | 'Attach file' | Accessible label for the attach button. | |
attachments | FileIntakeEntry[] | — | The accepted attachments (bindable). Populated by the picker / paste / drop
when allowAttachments is set. On submit these are handed to onSubmit and
— with clearOnSubmit — cleared from here **without** revoking their preview
object-URLs, because ownership transfers to the consumer's message list.
Removing chips through the built-in UI revokes their preview URLs for you.
Clearing or splicing this array **externally** (e.g. reassigning
bind:attachments) bypasses that cleanup — such mutations must revoke the
dropped entries' previews themselves via revokeIntakePreviews (exported
from $lib/utils/file-intake) to avoid leaking object-URLs. | |
autofocus | boolean | false | Focus the textarea on mount. | |
busy | boolean | false | A response is in flight: the send button is replaced by a stop button and Enter no longer submits. | |
class | string | — | Extra classes merged onto the root element. | |
clearOnSubmit | boolean | true | Clear the text and attachments after a successful submit. Preview URLs of
the submitted attachments are intentionally **not** revoked — see
attachments. | |
disabled | boolean | false | Disables the whole composer (textarea + buttons). | |
hint | Snippet | — | Helper line rendered under the composer (e.g. "Enter to send"). | |
label | string | 'Message' | Accessible name for the textarea (rendered as aria-label). | |
leading | Snippet | — | Content in the leading (left) action zone, after the attach button. | |
maxFiles | number | — | Maximum number of attachments across the whole list. | |
maxFileSize | number | — | Maximum attachment size in bytes. | |
maxRows | number | 8 | Maximum visible rows before the textarea scrolls internally. | |
minRows | number | 1 | Minimum visible rows of the auto-growing textarea. | |
onAttachmentReject | (rejections: FileIntakeRejection[]) => void | — | Called with the rejected files when an add is refused (bad type, too large,
over maxFiles, duplicate, or custom validate). The first rejection's
message also surfaces inline; it clears on the next successful add. | |
onStop | () => void | — | Fired by the stop button (shown in place of send while busy). | |
onValueChange | (value: string) => void | — | Called whenever the text changes (including the clear after submit). | |
placeholder | string | — | Placeholder text for the textarea. | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ PromptInput: {...} }}>.
Prefer this over class overrides when the requested look falls outside the
semantic intent palette. | |
preventDocumentDrop | boolean | true | Prevent browser navigation when a file is dropped outside the composer.
Only takes effect while allowAttachments is set. | |
removeAttachmentLabel | (name: string) => string | (name) => `Remove ${name}` | Accessible label factory for a chip's remove button. | |
sendLabel | string | 'Send' | Accessible label for the send button. | |
size variant | mdsm | md | Controls the dimensions, padding, and text size of the PromptInput. Affects the component's physical footprint. Available options: md, sm. | |
slotClasses | Partial<Record<PromptInputSlots, string>> | — | Per-slot class overrides. Slots: root | attachmentsStrip | attachmentChip | attachmentThumb | attachmentName | attachmentSize | attachmentRemove | textarea | actions | leading | trailing | attachButton | sendButton | stopButton | error | hint | |
stopLabel | string | 'Stop' | Accessible label for the stop button. | |
submitOn | entermod-enter | 'enter' | Which key gesture sends the message.
- enter (default) — Enter sends, Shift+Enter inserts a newline.
- mod-enter — Cmd/Ctrl+Enter sends, Enter inserts a newline.
Submission is always suppressed mid-IME-composition. | |
trailing | Snippet | — | Content in the trailing (right) action zone, before the send/stop button. | |
unstyled | boolean | — | Remove all default tv() classes. | |
validate | (file: File) => FileIntakeEntry['errors'] | null | — | Custom per-file validation. Return an errors array or null. | |
value | string | — | The composer's text (bindable). | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'children' | 'class' | 'onsubmit') | |
...PromptInputVariants variant | VariantProps | — | Styling variants from PromptInputVariants |
04 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
PromptInputProps | interface | props | 0 | — | |
FileIntakeEntry | interface | helper | 1 | — | |
FileIntakeRejection | interface | helper | 0 | — | |
PromptInputSlots | type | variant | 0 | Slot names derived from the tv() config — single source of truth for slotClasses. | |
PromptInputVariants | type | variant | 1 | — | |
FileIntakeStatus | type | helper | 0 | — | |
FileIntakeError | interface | helper | 0 | — | |
SlotNames | type | helper | 0 | Extracts the slot-name union from a slotted tv() config function — the
companion to VariantProps. The slot-mode overload returns
(props?) => { [K in keyof S]: SlotFn }, so keyof ReturnType<T> is exactly
the set of slot names a component declares in tv({ slots: … }).
Use it to type a component's slotClasses prop from the single source of
truth (its *.variants.ts) instead of hand-maintaining a parallel union
that silently drifts when a slot is added or renamed: | |
VariantProps | type | helper | 1 | — | |
FileIntakeErrorCode | type | helper | 0 | — |
05 Installation
Import
import { PromptInput } from '@urbicon-ui/blocks';
import type { PromptInputProps, FileIntakeEntry, FileIntakeRejection } from '@urbicon-ui/blocks';