Skip to content

Components / UI

Prompt Input

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.

Live demo

grows with the text · Enter sends · ⇧Enter breaks the line

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

With submitOnEnter: 327 B raw of its own on the runtime, measured on its own fixture. The default above ships none of it.

CSS 11.4 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 123 · Firefox 152 · Safari 26.2; needs field-sizing, has. Elsewhere: without field-sizing the textarea keeps its rows and a vertical resize handle; without :has() the send button does not dim.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import PromptInput from '@moonarc/core/PromptInput';
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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime, Animated Select, the base tokens and Spinner.

terminal
npx shadcn@latest add https://moonarc.dev/r/prompt-input.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy internal/PromptInputSubmitOnEnter.astro, runtime.ts, AnimatedSelect.astro and Spinner.astro.

PromptInput.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>

Props

PropTypeDefaultDescription
namestring'prompt'The textarea's field name.
placeholderstring'Ask anything'Placeholder; :placeholder-shown is what dims the send button.
valuestringnoneInitial text.
rowsnumber1Rows when empty: the minimum height, and the fixed height without field-sizing.
maxRowsnumber8Rows the box grows to before it scrolls.
models{ value, label, disabled?, group? }[]noneOptions for the model select (name="model"); none hides it.
modelstringnoneThe selected model.
attachmentsbooleanfalseShow the paperclip: a <label> around <input type="file" name="files" multiple>, visually hidden, reachable by Tab.
acceptstringnoneAccept list for the file input.
submitOnEnterbooleanfalseEnter 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.
labelstring'Message'Accessible name of the textarea.
requiredbooleantrueThe browser refuses an empty submit.
disabledbooleanfalseDisables every control.
sendLabelstring'Send'Visually hidden label of the send button.
attachLabelstring'Attach files'Visually hidden label of the attachment button.
modelLabelstring'Model'Accessible name of the model select.

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.

Why it is built this way

Replaces: PromptInput (Kokonut UI) · AI Input (Kibo UI) · react-textarea-autosize. See the migration table.

a native select · options stagger in

A native select with an animated picker.

Chrome 138 · not Firefox · Safari 27click

idle → pending → done · one attribute

One submit button with four faces (idle, pending with a spinner, done with a check that draws itself, error) and a width that follows the label.

every browserclick

hints take turns · Enter sends · the words blur away

Placeholders that take turns, and text that blurs away on submit.

newly · Chrome 125 · Firefox 128 · Safari 16.4clickalways

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown