Skip to main content
Urbicon UI

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.

Size Style variant
Submit on
<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

Bind the draft and set busy from your streaming state. While busy, the send button becomes a stop button, Enter no longer submits, and onStop aborts the in-flight response.
Enter to send · Shift+Enter for a new line
<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

Opt in with allowAttachments, then constrain with accept, maxFiles, and maxFileSize. Rejected files never enter the list, and onAttachmentReject reports why. Add images through the paperclip, drag-and-drop, or paste.
Attach via the paperclip, drag-and-drop, or paste a screenshot.
<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

The trailing snippet renders in the composer's right action zone, before the send button. Put a model selector, tool toggle, or temperature control here. leading (after the attach button) and hint (a line below) are the companion slots.
<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 props
35 props 1 required
Prop
Type
Default
Description

04 Types

Local type definitions used by this component.

10 types
Name
Kind
Category
Used by
Description

05 Installation

Import

import { PromptInput } from '@urbicon-ui/blocks';
import type { PromptInputProps, FileIntakeEntry, FileIntakeRejection } from '@urbicon-ui/blocks';