# Prompt Input (Moonarc)

The AI prompt box: a form with a textarea that grows with its content through field-sizing, a model select, an attachment button that is a real file input, and a send button that dims until something is typed and spins while the form carries data-pending. Zero JavaScript by default; submitOnEnter adds a measured script: Enter sends, Shift+Enter breaks the line, and an IME composition is never interrupted.

- Import: `import PromptInput from '@moonarc/core/PromptInput'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/prompt-input.json`
- Tier A · category ui · trigger click
- Readout: `<PromptInput models={models} attachments submitOnEnter>`
- Browser support: newly (Chrome 123 · Firefox 152 · Safari 26.2); elsewhere: without field-sizing the textarea keeps its rows and a vertical resize handle; without :has() the send button does not dim
- Measured cost: 0 B JS (CSS 11.4 kB raw)
- Page: https://moonarc.dev/components/prompt-input/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `name` | `string` | `'prompt'` | The textarea's field name. |
| `placeholder` | `string` | `'Ask anything'` | Placeholder; :placeholder-shown is what dims the send button. |
| `value` | `string` | none | Initial text. |
| `rows` | `number` | `1` | Rows when empty: the minimum height, and the fixed height without field-sizing. |
| `maxRows` | `number` | `8` | Rows the box grows to before it scrolls. |
| `models` | `{ value, label, disabled?, group? }[]` | none | Options for the model select (name="model"); none hides it. |
| `model` | `string` | none | The selected model. |
| `attachments` | `boolean` | `false` | Show the paperclip: a <label> around <input type="file" name="files" multiple>, visually hidden, reachable by Tab. |
| `accept` | `string` | none | Accept list for the file input. |
| `submitOnEnter` | `boolean` | `false` | Enter submits through form.requestSubmit() (validation and your handler run), Shift+Enter is a newline, nothing during IME composition. Adds the internal script, measured on its own fixture. |
| `label` | `string` | `'Message'` | Accessible name of the textarea. |
| `required` | `boolean` | `true` | The browser refuses an empty submit. |
| `disabled` | `boolean` | `false` | Disables every control. |
| `sendLabel` | `string` | `'Send'` | Visually hidden label of the send button. |
| `attachLabel` | `string` | `'Attach files'` | Visually hidden label of the attachment button. |
| `modelLabel` | `string` | `'Model'` | Accessible name of the model select. |

## Usage

```astro
---
const models = [{ value: 'fable', label: 'Fable 5.1' }, { value: 'sonnet', label: 'Sonnet 5' }];
---
<PromptInput action="/api/chat" method="post" models={models} model="fable" attachments submitOnEnter />

<script>
  // your handler: mark the form busy while the request runs
  const form = document.querySelector('.ma-prompt');
  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    if (form.hasAttribute('data-pending')) return;
    form.dataset.pending = '';
    try {
      await fetch('/api/chat', { method: 'POST', body: new FormData(form) });
    } finally {
      delete form.dataset.pending;
    }
  });
</script>
```

## Reduced motion

The button and the spinner switch without transitions; the textarea still grows (a layout, not an animation).

## With ClientRouter

A native form; nothing to bind. With submitOnEnter the internal script is bound through the shared runtime and rebinds after every navigation.

## Craft

- field-sizing: content makes the textarea follow its text with no measuring script; min and max heights are rows × the line height, so the cap is a row count. Engines without it keep the rows attribute and a resize handle, which is a plain, working textarea.
- The send button never becomes disabled for an empty box: it dims through :has(textarea:placeholder-shown) and stays clickable, so a click on it runs the browser's own validation message for required instead of doing nothing.
- The attachment control is a label around a real file input, hidden with clip-path rather than display: none: Tab reaches it, Space opens the picker, the files submit under name="files". A list of the chosen names is the host's to render; it is not built here.
- Busy state is the form's data-pending attribute, the NewsletterSection contract: a fetch handler toggles one attribute and the arrow gives way to the Spinner. The handler keeps the form in a variable: after an await, e.currentTarget is null, and a delete through it throws and leaves the form busy for good.
- submitOnEnter is a variant on purpose: the default fixture proves the form is 0 B, and the Enter script is measured alone. It calls requestSubmit(), so required, the submit event and your handler all still run; and it checks isComposing (and keyCode 229 for older IMEs) so a Japanese or Chinese reader confirming a candidate does not send the message.

## Replaces

- PromptInput (Kokonut UI)
- AI Input (Kibo UI)
- react-textarea-autosize

## Source

```astro
---
/**
 * PromptInput — the AI prompt box, zero JS by default. A small <form>: a
 * <textarea> that grows with its content (`field-sizing: content`, capped at
 * `maxRows`, scrolling past it; without field-sizing it keeps `rows` and a
 * vertical resize handle), a tool row with a model select (AnimatedSelect,
 * `name="model"`), an attachment button that is a <label> around a real
 * `<input type="file" multiple>` — visually hidden, still focusable — and
 * a send button that dims through `:has(textarea:placeholder-shown)` and
 * shows a spinner while the form carries `data-pending` (the
 * NewsletterSection contract). `submitOnEnter` renders one internal
 * component with a measured script: Enter sends, Shift+Enter breaks the
 * line, an IME composition is never interrupted.
 */
import type { HTMLAttributes } from 'astro/types';
import AnimatedSelect from './AnimatedSelect.astro';
import Spinner from './Spinner.astro';
import PromptInputSubmitOnEnter from './internal/PromptInputSubmitOnEnter.astro';

interface Model {
  value: string;
  label: string;
  disabled?: boolean;
  group?: string;
}

interface Props extends HTMLAttributes<'form'> {
  /** The textarea's name. */
  name?: string;
  placeholder?: string;
  /** Initial text. */
  value?: string;
  /** Rows shown when empty; the minimum height. */
  rows?: number;
  /** Rows the box grows to before it scrolls. */
  maxRows?: number;
  /** Models for the select (name="model"); none hides the select. */
  models?: Model[];
  /** The selected model. */
  model?: string;
  /** Show the attachment button (a real file input, name="files", multiple). */
  attachments?: boolean;
  /** Accept list for the file input. */
  accept?: string;
  /** Enter submits, Shift+Enter breaks the line; adds the measured script. */
  submitOnEnter?: boolean;
  /** Accessible name of the textarea. */
  label?: string;
  required?: boolean;
  disabled?: boolean;
  /** Send button label (visually hidden). */
  sendLabel?: string;
  /** Attachment button label (visually hidden). */
  attachLabel?: string;
  /** Accessible name of the model select. */
  modelLabel?: string;
}

const {
  name = 'prompt',
  placeholder = 'Ask anything',
  value,
  rows = 1,
  maxRows = 8,
  models,
  model,
  attachments = false,
  accept,
  submitOnEnter = false,
  label = 'Message',
  required = true,
  disabled = false,
  sendLabel = 'Send',
  attachLabel = 'Attach files',
  modelLabel = 'Model',
  class: className,
  style,
  ...rest
} = Astro.props;
const vars = [`--ma-prompt-rows:${Math.max(1, rows)}`, `--ma-prompt-max:${Math.max(rows, maxRows)}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<form class:list={['ma-prompt', className]} style={vars} {...rest}>
  <textarea class="ma-prompt__area" name={name} rows={Math.max(1, rows)} placeholder={placeholder} aria-label={label} required={required} disabled={disabled} set:text={value ?? ''} />
  <div class="ma-prompt__bar">
    {models && models.length > 0 && <AnimatedSelect class="ma-prompt__model" name="model" options={models} value={model} size="sm" aria-label={modelLabel} disabled={disabled} />}
    {attachments && (
      <label class="ma-prompt__attach">
        <input type="file" name="files" class="ma-prompt__file" multiple accept={accept} disabled={disabled} />
        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.5 12.2 21.3a5.5 5.5 0 0 1-7.8-7.8L13 4.9a3.7 3.7 0 0 1 5.2 5.2l-8.6 8.6a1.8 1.8 0 0 1-2.6-2.6l7.9-7.9" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" /></svg>
        <span class="ma-prompt__sr">{attachLabel}</span>
      </label>
    )}
    <button type="submit" class="ma-prompt__send" disabled={disabled}>
      <svg class="ma-prompt__arrow" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5m0 0-6 6m6-6 6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>
      <span class="ma-prompt__busy"><Spinner size={16} width={2} label="" /></span>
      <span class="ma-prompt__sr">{sendLabel}</span>
    </button>
  </div>
  {submitOnEnter && <PromptInputSubmitOnEnter />}
</form>

<style is:global>
  @layer components {
    :where(.ma-prompt) {
      display: grid;
      gap: 0.5em;
      padding: 0.75em 0.75em 0.6em;
      border: 1px solid var(--ma-edge);
      border-radius: 1.1em;
      background: var(--ma-panel);
      color: inherit;
      box-shadow: 0 1px 2px var(--ma-glow);
      transition: border-color var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-prompt:focus-within) {
      border-color: color-mix(in srgb, var(--ma-ink) 40%, transparent);
    }
    :where(.ma-prompt__area) {
      inline-size: 100%;
      margin: 0;
      padding: 0.25em 0.35em;
      border: 0;
      background: transparent;
      color: inherit;
      font: inherit;
      line-height: 1.5;
      resize: vertical;
      outline: none;
      /* grows with the text to maxRows, then scrolls; the minimum is the rows attribute */
      field-sizing: content;
      min-block-size: calc(var(--ma-prompt-rows) * 1.5em + 0.5em);
      max-block-size: calc(var(--ma-prompt-max) * 1.5em + 0.5em);
    }
    @supports (field-sizing: content) {
      :where(.ma-prompt__area) {
        resize: none;
      }
    }
    :where(.ma-prompt__area)::placeholder {
      color: color-mix(in srgb, var(--ma-ink) 40%, transparent);
      opacity: 1;
    }
    :where(.ma-prompt__area:disabled) {
      opacity: 0.5;
      cursor: not-allowed;
    }
    :where(.ma-prompt__bar) {
      display: flex;
      align-items: center;
      gap: 0.5em;
    }
    :where(.ma-prompt__model) {
      margin-inline-end: auto;
    }
    :where(.ma-prompt__bar > :last-child) {
      margin-inline-start: auto;
    }
    :where(.ma-prompt__attach),
    :where(.ma-prompt__send) {
      position: relative;
      display: grid;
      place-items: center;
      inline-size: 2.25em;
      block-size: 2.25em;
      margin: 0;
      padding: 0;
      border: 1px solid transparent;
      border-radius: 50%;
      background: transparent;
      color: inherit;
      font: inherit;
      cursor: pointer;
      transition:
        background-color var(--ma-duration-fast) var(--ma-ease-out),
        opacity var(--ma-duration-fast) var(--ma-ease-out),
        scale var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-prompt__attach > svg),
    :where(.ma-prompt__send > svg) {
      inline-size: 1.15em;
      block-size: 1.15em;
      grid-area: 1 / 1;
    }
    :where(.ma-prompt__attach:hover) {
      background: color-mix(in srgb, var(--ma-ink) 8%, transparent);
    }
    /* the file input: off screen, not display: none — Tab reaches it and Space opens the picker; the label draws the focus */
    :where(.ma-prompt__file) {
      position: absolute;
      inline-size: 1px;
      block-size: 1px;
      margin: -1px;
      padding: 0;
      border: 0;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
    :where(.ma-prompt__attach:has(.ma-prompt__file:focus-visible)),
    :where(.ma-prompt__send:focus-visible) {
      outline: 2px solid var(--ma-ink);
      outline-offset: 2px;
    }
    :where(.ma-prompt__send) {
      background: var(--ma-ink);
      color: var(--ma-panel);
    }
    :where(.ma-prompt__send:hover) {
      scale: 1.06;
    }
    :where(.ma-prompt__send:active) {
      scale: 0.96;
    }
    /* nothing typed: the button reads as inert but stays clickable — required is what refuses the submit */
    :where(.ma-prompt:has(.ma-prompt__area:placeholder-shown) .ma-prompt__send) {
      opacity: 0.35;
      scale: 1;
    }
    :where(.ma-prompt__send:disabled) {
      cursor: not-allowed;
      opacity: 0.35;
      scale: 1;
      transition: none;
    }
    :where(.ma-prompt__busy) {
      grid-area: 1 / 1;
      display: grid;
      opacity: 0;
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-prompt[data-pending] .ma-prompt__busy) {
      opacity: 1;
    }
    :where(.ma-prompt[data-pending] .ma-prompt__arrow) {
      opacity: 0;
    }
    :where(.ma-prompt[data-pending] .ma-prompt__send) {
      opacity: 1;
      pointer-events: none;
    }
    :where(.ma-prompt__sr) {
      position: absolute;
      inline-size: 1px;
      block-size: 1px;
      margin: -1px;
      padding: 0;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
    @media (forced-colors: active) {
      :where(.ma-prompt) {
        border-color: ButtonText;
      }
      :where(.ma-prompt__send) {
        border-color: ButtonText;
        forced-color-adjust: none;
        background: ButtonText;
        color: Canvas;
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-prompt),
      :where(.ma-prompt__attach),
      :where(.ma-prompt__send),
      :where(.ma-prompt__busy) {
        transition: none;
        scale: 1;
      }
    }
  }
</style>

```
