Usage
The ChatPrompt component renders a <form> element and extends the Textarea component so you can pass any property such as icon, placeholder, autofocus, etc.
<script setup lang="ts">
import type { SelectItem } from 'pohon-ui';
const input = ref('');
const model = ref('claude-opus-4.6');
const models = [
{ label: 'Claude Opus 4.6', value: 'claude-opus-4.6', icon: 'i-simple-icons-anthropic' },
{ label: 'Gemini 3 Pro', value: 'gemini-3-pro', icon: 'i-simple-icons-googlegemini' },
{ label: 'GPT-5', value: 'gpt-5', icon: 'i-simple-icons-openai' },
] satisfies Array<SelectItem>;
function onSubmit() {
input.value = '';
}
</script>
<template>
<PChatPrompt
v-model="input"
class="w-full"
@submit="onSubmit"
>
<template #footer>
<PButton
icon="i-lucide-plus"
color="neutral"
variant="ghost"
size="sm"
/>
<div class="flex gap-1.5 items-center">
<PSelect
v-model="model"
:items="models"
:icon="models.find(item => item.value === model)?.icon"
placeholder="Select a model"
variant="ghost"
size="sm"
square
/>
<PChatPromptSubmit size="sm" />
</div>
</template>
</PChatPrompt>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { SelectItem } from 'pohon-ui';
const input = ref('');
const model = ref('claude-opus-4.6');
const models = [
{ label: 'Claude Opus 4.6', value: 'claude-opus-4.6', icon: 'i-simple-icons-anthropic' },
{ label: 'Gemini 3 Pro', value: 'gemini-3-pro', icon: 'i-simple-icons-googlegemini' },
{ label: 'GPT-5', value: 'gpt-5', icon: 'i-simple-icons-openai' },
] satisfies Array<SelectItem>;
function onSubmit() {
input.value = '';
}
</script>
<template>
<PChatPrompt
v-model="input"
class="w-full"
@submit="onSubmit"
>
<template #footer>
<PButton
icon="i-lucide-plus"
color="neutral"
variant="ghost"
size="sm"
/>
<div class="flex gap-1.5 items-center">
<PSelect
v-model="model"
:items="models"
:icon="models.find(item => item.value === model)?.icon"
placeholder="Select a model"
variant="ghost"
size="sm"
square
/>
<PChatPromptSubmit size="sm" />
</div>
</template>
</PChatPrompt>
</template>
- The form is submitted when the user presses ↵ or when the user clicks on the submit button. Set the
submit-on-enterprop tofalseto submit with ⌃Ctrl + ↵ (or cmd + ↵ on macOS) instead, allowing ↵ to insert a newline. - The textarea is blurred when Esc is pressed and emits a
closeevent.
Variant
Use the variant prop to change the style of the prompt. Defaults to outline.
<template>
<PChatPrompt variant="soft" />
</template>Examples
With an Editor
Compose the #header, #body and #footer slots to build a rich prompt: file attachments, an Editor with @ mentions and / commands through EditorMentionMenu, and a mode selector.
<script setup lang="ts">
import type { EditorMentionMenuItem, SelectItem } from 'pohon-ui';
import { Extension } from '@tiptap/core';
const input = ref('');
const mode = ref('auto');
const attachments = ref<Array<{ name: string; src?: string }>>([]);
const fileInputRef = useTemplateRef('fileInputRef');
const files: Array<EditorMentionMenuItem> = [
{ label: 'app.vue', icon: 'i-vscode-icons-file-type-vue' },
{ label: 'nuxt.config.ts', icon: 'i-vscode-icons-file-type-nuxt' },
{ label: 'package.json', icon: 'i-vscode-icons-file-type-json' },
{ label: 'README.md', icon: 'i-vscode-icons-file-type-markdown' },
{ label: 'AuthForm.vue', icon: 'i-vscode-icons-file-type-vue' },
{ label: 'useChat.ts', icon: 'i-vscode-icons-file-type-typescript' },
];
const commands: Array<EditorMentionMenuItem> = [
{ label: 'init', icon: 'i-lucide-sparkles' },
{ label: 'review', icon: 'i-lucide-search-code' },
{ label: 'security-review', icon: 'i-lucide-shield-check' },
{ label: 'clear', icon: 'i-lucide-eraser' },
{ label: 'compact', icon: 'i-lucide-fold-vertical' },
{ label: 'config', icon: 'i-lucide-settings' },
];
const modes = [
{ label: 'Manual', value: 'manual', icon: 'i-lucide-hand' },
{ label: 'Edit automatically', value: 'edit', icon: 'i-lucide-code-xml' },
{ label: 'Plan mode', value: 'plan', icon: 'i-lucide-list-checks' },
{ label: 'Auto mode', value: 'auto', icon: 'i-lucide-zap' },
] satisfies Array<SelectItem>;
// SSR-safe target so the menus aren't clipped by overflow
const appendToBody = false ? () => document.body : undefined;
function onFilesChange(event: Event) {
const target = event.target as HTMLInputElement;
for (const file of Array.from(target.files ?? [])) {
attachments.value.push({
name: file.name,
src: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
});
}
target.value = '';
}
function onSubmit() {
console.log('submit', input.value);
input.value = '';
attachments.value = [];
}
</script>
<template>
<PChatPrompt
v-model="input"
class="p-0 gap-0 w-full"
placeholder="Press / to open the command menu"
:ui="{
header: 'px-2.5 py-2 border-b border-border',
footer: 'px-2.5 py-2 border-t border-border',
}"
@submit="onSubmit"
>
<template
v-if="attachments.length"
#header
>
<PButton
v-for="(file, index) in attachments"
:key="index"
:label="file.name"
:avatar="file.src ? { src: file.src } : undefined"
:icon="file.src ? undefined : 'i-lucide-file'"
color="neutral"
variant="soft"
size="xs"
square
trailing-icon="i-lucide-x"
@click="attachments.splice(index, 1)"
/>
</template>
<template #body="{ submit, placeholder }">
<PEditor
v-slot="{ editor }"
v-model="input"
content-type="markdown"
:starter-kit="false"
:placeholder="placeholder"
class="min-h-12 w-full"
:ui="{ base: 'p-2.5! [&_.mention]:bg-primary/5 [&_.mention]:rounded-sm [&_.mention]:px-0.5 [&_.mention]:py-0.25' }"
:extensions="[Extension.create({
name: 'chatPromptSubmit',
priority: 1000,
addKeyboardShortcuts: () => ({ Enter: () => (submit(), true) }),
})]"
>
<PEditorMentionMenu
:editor="editor"
char="@"
plugin-key="mention"
:items="files"
:append-to="appendToBody"
/>
<PEditorMentionMenu
:editor="editor"
char="/"
plugin-key="command"
:items="commands"
:append-to="appendToBody"
/>
</PEditor>
</template>
<template #footer>
<div class="flex gap-0.5 items-center">
<PButton
icon="i-lucide-plus"
color="neutral"
variant="ghost"
aria-label="Attach files"
size="sm"
@click="fileInputRef?.click()"
/>
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="onFilesChange"
>
</div>
<div class="flex gap-1 items-center">
<PSelect
v-model="mode"
:items="modes"
:icon="modes.find(item => item.value === mode)?.icon"
color="neutral"
variant="ghost"
size="sm"
square
/>
<PChatPromptSubmit
size="sm"
:disabled="!input.trim()"
/>
</div>
</template>
</PChatPrompt>
</template>
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import type { EditorMentionMenuItem, SelectItem } from 'pohon-ui';
import { Extension } from '@tiptap/core';
const input = ref('');
const mode = ref('auto');
const attachments = ref<Array<{ name: string; src?: string }>>([]);
const fileInputRef = useTemplateRef('fileInputRef');
const files: Array<EditorMentionMenuItem> = [
{ label: 'app.vue', icon: 'i-vscode-icons-file-type-vue' },
{ label: 'nuxt.config.ts', icon: 'i-vscode-icons-file-type-nuxt' },
{ label: 'package.json', icon: 'i-vscode-icons-file-type-json' },
{ label: 'README.md', icon: 'i-vscode-icons-file-type-markdown' },
{ label: 'AuthForm.vue', icon: 'i-vscode-icons-file-type-vue' },
{ label: 'useChat.ts', icon: 'i-vscode-icons-file-type-typescript' },
];
const commands: Array<EditorMentionMenuItem> = [
{ label: 'init', icon: 'i-lucide-sparkles' },
{ label: 'review', icon: 'i-lucide-search-code' },
{ label: 'security-review', icon: 'i-lucide-shield-check' },
{ label: 'clear', icon: 'i-lucide-eraser' },
{ label: 'compact', icon: 'i-lucide-fold-vertical' },
{ label: 'config', icon: 'i-lucide-settings' },
];
const modes = [
{ label: 'Manual', value: 'manual', icon: 'i-lucide-hand' },
{ label: 'Edit automatically', value: 'edit', icon: 'i-lucide-code-xml' },
{ label: 'Plan mode', value: 'plan', icon: 'i-lucide-list-checks' },
{ label: 'Auto mode', value: 'auto', icon: 'i-lucide-zap' },
] satisfies Array<SelectItem>;
// SSR-safe target so the menus aren't clipped by overflow
const appendToBody = false ? () => document.body : undefined;
function onFilesChange(event: Event) {
const target = event.target as HTMLInputElement;
for (const file of Array.from(target.files ?? [])) {
attachments.value.push({
name: file.name,
src: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
});
}
target.value = '';
}
function onSubmit() {
console.log('submit', input.value);
input.value = '';
attachments.value = [];
}
</script>
<template>
<PChatPrompt
v-model="input"
class="p-0 gap-0 w-full"
placeholder="Press / to open the command menu"
:ui="{
header: 'px-2.5 py-2 border-b border-border',
footer: 'px-2.5 py-2 border-t border-border',
}"
@submit="onSubmit"
>
<template
v-if="attachments.length"
#header
>
<PButton
v-for="(file, index) in attachments"
:key="index"
:label="file.name"
:avatar="file.src ? { src: file.src } : undefined"
:icon="file.src ? undefined : 'i-lucide-file'"
color="neutral"
variant="soft"
size="xs"
square
trailing-icon="i-lucide-x"
@click="attachments.splice(index, 1)"
/>
</template>
<template #body="{ submit, placeholder }">
<PEditor
v-slot="{ editor }"
v-model="input"
content-type="markdown"
:starter-kit="false"
:placeholder="placeholder"
class="min-h-12 w-full"
:ui="{ base: 'p-2.5! [&_.mention]:bg-primary/5 [&_.mention]:rounded-sm [&_.mention]:px-0.5 [&_.mention]:py-0.25' }"
:extensions="[Extension.create({
name: 'chatPromptSubmit',
priority: 1000,
addKeyboardShortcuts: () => ({ Enter: () => (submit(), true) }),
})]"
>
<PEditorMentionMenu
:editor="editor"
char="@"
plugin-key="mention"
:items="files"
:append-to="appendToBody"
/>
<PEditorMentionMenu
:editor="editor"
char="/"
plugin-key="command"
:items="commands"
:append-to="appendToBody"
/>
</PEditor>
</template>
<template #footer>
<div class="flex gap-0.5 items-center">
<PButton
icon="i-lucide-plus"
color="neutral"
variant="ghost"
aria-label="Attach files"
size="sm"
@click="fileInputRef?.click()"
/>
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="onFilesChange"
>
</div>
<div class="flex gap-1 items-center">
<PSelect
v-model="mode"
:items="modes"
:icon="modes.find(item => item.value === mode)?.icon"
color="neutral"
variant="ghost"
size="sm"
square
/>
<PChatPromptSubmit
size="sm"
:disabled="!input.trim()"
/>
</div>
</template>
</PChatPrompt>
</template>
#body slot replaces the internal textarea and exposes submit and close handlers, so you can wire the editor's keyboard shortcuts to the form. When a mention menu is open, pressing ↵ selects the highlighted item instead of submitting.As home page
You can also use it in your chat interface home page.
<script setup lang="ts">
import { useChat } from '@ai-sdk/vue';
const input = ref('');
const { messages, status, sendMessage } = useChat();
async function onSubmit() {
sendMessage({ text: input.value });
// Navigate to chat page after first message
if (messages.value.length === 1) {
await navigateTo('/chat');
}
}
</script>
<template>
<PDashboardPanel>
<template #body>
<PContainer>
<h1>How can I help you today?</h1>
<PChatPrompt
v-model="input"
@submit="onSubmit"
>
<PChatPromptSubmit :status="status" />
</PChatPrompt>
</PContainer>
</template>
</PDashboardPanel>
</template>
API
Props
| Prop | Default | Type |
|---|---|---|
as | 'form' | anyThe element or component this component should render as. |
placeholder | t('chatPrompt.placeholder') | stringThe placeholder text for the textarea. |
color | 'primary' | "error" | "primary" | "secondary" | "success" | "info" | "warning" | "neutral" |
variant | 'outline' | "outline" | "soft" | "subtle" | "naked" |
submitOnEnter | true | boolean When |
status | "error" | "submitted" | "streaming" | "ready"The status of the chat. Submitting is ignored while it is | |
error | Error | |
rows | number | |
autofocus | boolean | |
autofocusDelay | number | |
autoresize | boolean | |
autoresizeDelay | number | |
maxrows | number | |
icon | anyDisplay an icon based on the | |
avatar | AvatarPropsDisplay an avatar on the left side. | |
loading | boolean When | |
loadingIcon | appConfig.ui.icons.loading | anyThe icon when the |
disabled | boolean | |
modelValue | string | |
ui | { root?: SlotClass; header?: SlotClass; body?: SlotClass; footer?: SlotClass; base?: SlotClass; } & { root?: SlotClass; base?: SlotClass; leading?: SlotClass; leadingIcon?: SlotClass; leadingAvatar?: SlotClass; leadingAvatarSize?: SlotClass; trailing?: SlotClass; trailingIcon?: SlotClass; } |
Slots
| Slot | Type |
|---|---|
header | {} |
footer | {} |
body | { submit: (event?: Event | undefined) => void; close: (event?: Event | undefined) => void; placeholder: string; disabled: boolean; ui: object; }Replace the internal textarea, e.g. to render an Editor with mentions. |
leading | { ui: { root: (props?: Record<string, any> | undefined) => string | undefined; base: (props?: Record<string, any> | undefined) => string | undefined; leading: (props?: Record<string, any> | undefined) => string | undefined; leadingIcon: (props?: Record<string, any> | undefined) => string | undefined; leadingAvatar: (props?: Record<string, any> | undefined) => string | undefined; leadingAvatarSize: (props?: Record<string, any> | undefined) => string | undefined; trailing: (props?: Record<string, any> | undefined) => string | undefined; trailingIcon: (props?: Record<string, any> | undefined) => string | undefined; }; } |
default | { ui: { root: (props?: Record<string, any> | undefined) => string | undefined; base: (props?: Record<string, any> | undefined) => string | undefined; leading: (props?: Record<string, any> | undefined) => string | undefined; leadingIcon: (props?: Record<string, any> | undefined) => string | undefined; leadingAvatar: (props?: Record<string, any> | undefined) => string | undefined; leadingAvatarSize: (props?: Record<string, any> | undefined) => string | undefined; trailing: (props?: Record<string, any> | undefined) => string | undefined; trailingIcon: (props?: Record<string, any> | undefined) => string | undefined; }; } |
trailing | { ui: { root: (props?: Record<string, any> | undefined) => string | undefined; base: (props?: Record<string, any> | undefined) => string | undefined; leading: (props?: Record<string, any> | undefined) => string | undefined; leadingIcon: (props?: Record<string, any> | undefined) => string | undefined; leadingAvatar: (props?: Record<string, any> | undefined) => string | undefined; leadingAvatarSize: (props?: Record<string, any> | undefined) => string | undefined; trailing: (props?: Record<string, any> | undefined) => string | undefined; trailingIcon: (props?: Record<string, any> | undefined) => string | undefined; }; } |
Emits
| Event | Type |
|---|---|
close | [event: Event] |
submit | [event: Event] |
update:modelValue | [value: string] |
Expose
When accessing the component via a template ref, you can use the following:
| Name | Type |
|---|---|
textareaRef | Ref<HTMLTextAreaElement | null> |
Theme
export default defineAppConfig({
ui: {
chatPrompt: {
slots: {
root: 'relative flex flex-col items-stretch gap-2 px-2.5 py-2 w-full rounded-lg backdrop-blur-sm',
header: 'flex items-center gap-1.5',
body: 'items-start gap-1.5',
footer: 'flex items-center justify-between gap-1.5',
base: 'px-0 rounded-none'
},
variants: {
color: {
primary: '',
secondary: '',
success: '',
info: '',
warning: '',
error: '',
neutral: ''
},
variant: {
outline: {
root: 'bg-background/75 ring ring-border'
},
soft: {
root: 'bg-background-elevated/50'
},
subtle: {
root: 'bg-background-elevated/50 ring ring-border'
},
naked: {
root: ''
}
}
},
compoundVariants: [
{
color: 'primary',
variant: [
'outline',
'subtle'
],
class: {
root: 'outline-primary/25 has-[textarea:focus-visible]:outline-3 has-[textarea:focus-visible]:ring-primary has-[[contenteditable]:focus-visible]:outline-3 has-[[contenteditable]:focus-visible]:ring-primary'
}
},
{
color: 'primary',
variant: 'soft',
class: {
root: 'outline-primary/25 has-[textarea:focus-visible]:outline-3 has-[[contenteditable]:focus-visible]:outline-3'
}
},
{
color: 'neutral',
variant: [
'outline',
'subtle'
],
class: {
root: 'outline-border-inverted/25 has-[textarea:focus-visible]:outline-3 has-[textarea:focus-visible]:ring-border-inverted has-[[contenteditable]:focus-visible]:outline-3 has-[[contenteditable]:focus-visible]:ring-border-inverted'
}
},
{
color: 'neutral',
variant: 'soft',
class: {
root: 'outline-border-inverted/25 has-[textarea:focus-visible]:outline-3 has-[[contenteditable]:focus-visible]:outline-3'
}
}
],
defaultVariants: {
color: 'primary',
variant: 'outline'
}
}
}
})import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import ui from 'pohon-ui/vite'
export default defineConfig({
plugins: [
vue(),
ui({
ui: {
chatPrompt: {
slots: {
root: 'relative flex flex-col items-stretch gap-2 px-2.5 py-2 w-full rounded-lg backdrop-blur-sm',
header: 'flex items-center gap-1.5',
body: 'items-start gap-1.5',
footer: 'flex items-center justify-between gap-1.5',
base: 'px-0 rounded-none'
},
variants: {
color: {
primary: '',
secondary: '',
success: '',
info: '',
warning: '',
error: '',
neutral: ''
},
variant: {
outline: {
root: 'bg-background/75 ring ring-border'
},
soft: {
root: 'bg-background-elevated/50'
},
subtle: {
root: 'bg-background-elevated/50 ring ring-border'
},
naked: {
root: ''
}
}
},
compoundVariants: [
{
color: 'primary',
variant: [
'outline',
'subtle'
],
class: {
root: 'outline-primary/25 has-[textarea:focus-visible]:outline-3 has-[textarea:focus-visible]:ring-primary has-[[contenteditable]:focus-visible]:outline-3 has-[[contenteditable]:focus-visible]:ring-primary'
}
},
{
color: 'primary',
variant: 'soft',
class: {
root: 'outline-primary/25 has-[textarea:focus-visible]:outline-3 has-[[contenteditable]:focus-visible]:outline-3'
}
},
{
color: 'neutral',
variant: [
'outline',
'subtle'
],
class: {
root: 'outline-border-inverted/25 has-[textarea:focus-visible]:outline-3 has-[textarea:focus-visible]:ring-border-inverted has-[[contenteditable]:focus-visible]:outline-3 has-[[contenteditable]:focus-visible]:ring-border-inverted'
}
},
{
color: 'neutral',
variant: 'soft',
class: {
root: 'outline-border-inverted/25 has-[textarea:focus-visible]:outline-3 has-[[contenteditable]:focus-visible]:outline-3'
}
}
],
defaultVariants: {
color: 'primary',
variant: 'outline'
}
}
}
})
]
})