a native select · options stagger in
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.
Adjust · props are custom properties, set inline, so you edit the running instance
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
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 moonarcpnpm astro add moonarcbunx astro add moonarc---
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.
npx shadcn@latest add https://moonarc.dev/r/prompt-input.jsonThe 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 — 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
| 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. |
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
- 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. See the migration table.
Related
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.
hints take turns · Enter sends · the words blur away
Placeholders that take turns, and text that blurs away on submit.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown