AI Prompt Input
The composer. It re-bases on Nuxt UI's ChatPrompt and ChatPromptSubmit, so the
box, the field and the button belong to the design system rather than to this
module — and you inherit autoresize, the Enter / Shift+Enter and Escape keys, the
IME guard on Enter, focus rings, and the send / stop / reload glyph set.
The IME guard is worth calling out: accepting a Japanese candidate with Enter used to both submit the prompt and swallow the character. That is handled for you.
The module keeps only the motion it owns — the attachment chips, the character
counter, and the press physics plus crossfade on the submit button. The box's own
chrome is the theme's, and the only status it carries is an accent ring on error
and done.
Pair it with AI Messages for the transcript and AI Orb Face for the character above it.
Usage
Bind v-model to the draft, set state, and handle submit. Everything else —
attachments, the counter, the button swap — is opt-in through attachable,
removable and max-length.
type something, or attach a file
<template>
<div class="flex w-full max-w-lg flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<UButton
v-for="value in states"
:key="value"
:color="state === value ? 'primary' : 'neutral'"
:label="value"
size="sm"
:variant="state === value ? 'solid' : 'outline'"
@click="state = value"
/>
</div>
<MAiPromptInput
v-model="draft"
:attachments="attachments"
attachable
:max-length="180"
placeholder="Ask anything…"
removable
:state="state"
@attach="onAttach"
@remove-attachment="onRemoveAttachment"
@submit="onSubmit"
@stop="state = 'idle'"
>
<UButton
color="neutral"
icon="i-lucide-globe"
size="xs"
variant="ghost"
/>
</MAiPromptInput>
<p class="text-xs text-muted">
{{ draft ? `last submit: "${submitted}"` : 'type something, or attach a file' }}
</p>
</div>
</template>
<script setup lang="ts">
type Attachment = {
id: string
name: string
size?: number
}
type DemoState = 'idle' | 'listening' | 'thinking' | 'streaming' | 'done' | 'error'
const states: DemoState[] = ['idle', 'thinking', 'streaming', 'done', 'error']
const draft = ref('')
const submitted = ref('')
const state = ref<DemoState>('idle')
const attachments = ref<Attachment[]>([])
const onAttach = () => {
attachments.value = [
...attachments.value,
{ id: `f-${attachments.value.length + 1}`, name: 'token-audit.md', size: 18_432 },
]
}
const onRemoveAttachment = (id: string) => {
attachments.value = attachments.value.filter(file => file.id !== id)
}
const onSubmit = (value: string) => {
submitted.value = value
attachments.value = []
state.value = 'streaming'
}
</script>
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
state | 'idle' | 'listening' | 'thinking' | 'streaming' | 'done' | 'error' | 'idle' | Shared AI state. A turn in flight turns submit into stop. error and done
add an accent ring to the box; nothing else about the box changes, because a
composer that reshapes per state fights the text the user is writing. |
modelValue | string | - | Controlled draft. Leave it undefined and the component owns the value; bind it
with v-model and it emits update:modelValue instead of clearing itself on
submit. Submitting always emits the trimmed value. |
attachments | AIPromptAttachment[] | [] | Files already attached to the draft, as { id, name, size? }. size is in
bytes and is rendered as B, KB or MB when present. Attachments live in the
header because they belong to the draft, not to the message. |
attachable | boolean | false | Show the paperclip control. It emits attach and does nothing else — opening a
file dialog is your call, because only you know where your files live. |
removable | boolean | false | Show a remove control per attachment. It emits removeAttachment with the
attachment's id; removing it from the array is your job. |
maxLength | number | - | Shows a draft/maxLength counter once the draft passes 80% of the limit, and
turns the counter red past it. It does not truncate — enforce the limit in
your submit handler if you need it. |
submitOnEnter | boolean | true | true submits on Enter and inserts a newline on Shift+Enter. false inverts
that, so Enter inserts a newline and Ctrl/Cmd+Enter submits — the right default
for composers holding multi-line prompts. |
placeholder | string | 'Ask anything…' | Forwarded to the underlying field. Without an ariaLabel, the placeholder is
the only thing naming the textarea. |
ariaLabel | string | - | Accessible name for the field, forwarded to the textarea rather than the box — which is where it belongs. Set it when the composer is embedded in a larger surface that needs to say what the prompt is for. |
autofocus | boolean | false | Focus the field on mount. Off by default, unlike ChatPrompt: a composer that
grabs focus the moment it renders is hostile inside a page that also has
something to say. |
disabled | boolean | false | Disable the field and the attach control. Submitting is dropped while disabled. |
error | Error | - | Passed to the underlying component, which renders it. |
ui | ChatPromptProps['ui'] | - | Themed slot overrides, merged over this module's own spacing rather than replacing it. |
class | string | - | Merged onto the underlying prompt box. |
Events
| Event | Payload | Description |
|---|---|---|
submit | [value: string] | The draft was submitted. The payload is trimmed and never empty — the underlying component will not submit an empty draft. |
update:modelValue | [value: string] | Emitted on every keystroke. Present whether or not you bound v-model. |
attach | - | The paperclip was pressed. Open your own file dialog. |
removeAttachment | [id: string] | A chip's remove button was pressed. Filter that id out of your array. |
stop | - | The submit button was pressed while a turn was in flight. |
reload | - | The submit button's reload affordance was used. |
close | - | The underlying prompt requested closing. |
Slots
| Slot | Props | Description |
|---|---|---|
default | - | Renders to the left of the submit button, after the attach control — the place for a model picker, a mode toggle, or a web-search toggle. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.aiPromptInput in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | gap-0 |
footer | - | pt-2 |
attachments | - | flex list-none flex-wrap gap-1.5 overflow-hidden py-1.5 |
attachment | - | flex items-center gap-1.5 rounded-lg border border-default bg-muted/50 py-1 pr-1 pl-2 text-xs |
attachmentName | - | max-w-40 truncate |
attachmentSize | - | text-muted |
remove | - | cursor-pointer rounded-md p-0.5 text-muted transition-colors hover:bg-muted hover:text-highlighted |
removeIcon | - | size-3 |
tools | - | flex min-w-0 items-center gap-1 |
attach | - | cursor-pointer rounded-lg p-2 text-muted transition-colors hover:bg-muted hover:text-highlighted |
attachIcon | - | size-4 |
actions | - | relative flex items-center gap-2 |
submit | - | flex |
counter | - | text-xs tabular-nums |
Accessibility
ariaLabelgoes on the textarea, not the box, so the announced name matches the element that actually takes focus.- The attach button and each chip's remove button are native
<button>s witharia-labels — "Attach a file", and "Remove filename". The icons inside them arearia-hidden. - The counter uses
tabular-nums, so the digits do not reflow as the count climbs, and it turns to the error colour past the limit. - The field grows to at most 8 rows and then scrolls. A long draft stays editable rather than pushing the whole page down.
- An empty draft cannot be submitted, but stop always works. That asymmetry comes
from the underlying submit component honouring
disabledonly in thereadystate, and it is the correct rule: you must always be able to interrupt a turn. - Enter submissions are dropped while the field is disabled.
- Under
prefers-reduced-motion: reducethe attachment list, the chips, the counter and the submit swap all resolve to instant state changes with no travel and no press physics. - The submit button is keyed on
chatStatusand swapped withmode="popLayout", so the outgoing button leaves flow immediately instead of the row jumping mid-swap. That is a layout guarantee, not decoration: a composer that jumps is a composer people misclick.
Performance
- Every animation variant is hoisted to a module-level constant. A re-render on every keystroke cannot allocate them.
- The attachment list and chips use
layoutanimation so removing a chip slides the survivors into place, but only when reduced motion is off. - The submit swap is paced by the state:
duration: 0.18 / speed. Athinkingcomposer is faster than anidleone, so the control stays responsive during the state where a user is most likely to interrupt. - The draft is a computed with a getter and a setter, so controlled and uncontrolled modes are one code path rather than two branches in the template.
- Unmanaged draft only: submitting clears an internal value, never a bound one. Wiping a controlled draft would fight whoever owns it.
uiis rebuilt as a computed so the accent ring can join the root's classes without allocating on every keystroke.