Prompt Input UI
The core input for AI chat products — with attachments, model selector, tools, voice and loading states.
promptinputchattextareacomposer
Props
Also takes onSubmit, onAttach, onRemoveAttachment, onVoice, onStop.
Component source
Single-file React + Tailwind component. No dependencies — drop it into any project with Tailwind configured.
prompt-input.tsx
"use client";
import * as React from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type Attachment = {
id: string;
name: string;
size?: string;
type?: "image" | "file";
};
export type ModelOption = {
id: string;
name: string;
hint?: string;
/**
* Optional leading mark, e.g. the provider's logo. A slot rather than a
* built-in lookup so this component stays dependency-free — pass whatever
* icon element you already have.
*/
icon?: React.ReactNode;
};
export type PromptInputProps = {
placeholder?: string;
models?: ModelOption[];
defaultModel?: string;
attachments?: Attachment[];
onAttach?: () => void;
onRemoveAttachment?: (id: string) => void;
onVoice?: () => void;
onSubmit?: (value: string, model?: string) => void;
onStop?: () => void;
loading?: boolean;
disabled?: boolean;
error?: string | null;
showWebSearch?: boolean;
showTools?: boolean;
className?: string;
};
/* ------------------------------------------------------------------ */
/* Icons (inline SVG, no dependencies) */
/* ------------------------------------------------------------------ */
function PlusIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="16" height="16" {...props}>
<path d="M12 5v14M5 12h14" />
</svg>
);
}
function GlobeIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="14" height="14" {...props}>
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 0 20 15.3 15.3 0 0 1 0-20Z" />
</svg>
);
}
function WrenchIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="14" height="14" {...props}>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
);
}
function MicIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="16" height="16" {...props}>
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3" />
</svg>
);
}
function ArrowUpIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="16" height="16" {...props}>
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
);
}
function StopIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14" {...props}>
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
);
}
function ChevronDownIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="14" height="14" {...props}>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
function XIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="12" height="12" {...props}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
function FileIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="14" height="14" {...props}>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2Z" />
<path d="M14 2v6h6" />
</svg>
);
}
function ImageIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="14" height="14" {...props}>
<rect width="18" height="18" x="3" y="3" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
);
}
function AlertIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="14" height="14" {...props}>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
);
}
/* ------------------------------------------------------------------ */
/* PromptInput */
/* ------------------------------------------------------------------ */
export function PromptInput({
placeholder = "Ask anything…",
models,
defaultModel,
attachments = [],
onAttach,
onRemoveAttachment,
onVoice,
onSubmit,
onStop,
loading = false,
disabled = false,
error = null,
showWebSearch = true,
showTools = true,
className = "",
}: PromptInputProps) {
const [value, setValue] = React.useState("");
const [model, setModel] = React.useState(defaultModel ?? models?.[0]?.id);
const [modelOpen, setModelOpen] = React.useState(false);
const [webSearch, setWebSearch] = React.useState(false);
const [tools, setTools] = React.useState(false);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const menuRef = React.useRef<HTMLDivElement>(null);
const activeModel = models?.find((m) => m.id === model);
const canSubmit = value.trim().length > 0 && !disabled && !loading;
/* Auto-grow textarea */
React.useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "0px";
el.style.height = Math.min(el.scrollHeight, 200) + "px";
}, [value]);
/* Close model menu on outside click / Escape */
React.useEffect(() => {
if (!modelOpen) return;
function onClick(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setModelOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setModelOpen(false);
}
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKey);
};
}, [modelOpen]);
function submit() {
if (!canSubmit) return;
onSubmit?.(value.trim(), activeModel?.id);
setValue("");
}
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}
const iconButton =
"inline-flex h-8 w-8 items-center justify-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:opacity-40 disabled:pointer-events-none dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100";
const toggleButton = (active: boolean) =>
`inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium transition-colors disabled:opacity-40 disabled:pointer-events-none ${
active
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
}`;
return (
<div className={`w-full ${className}`}>
<div
className={`w-full rounded-2xl border bg-white shadow-sm transition-colors dark:bg-zinc-900 ${
error
? "border-red-300 dark:border-red-900"
: "border-zinc-200 focus-within:border-zinc-400 dark:border-zinc-800 dark:focus-within:border-zinc-600"
} ${disabled ? "opacity-60" : ""}`}
>
{/* Attachments */}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 px-3 pt-3">
{attachments.map((file) => (
<span
key={file.id}
className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 bg-zinc-50 py-1 pl-2 pr-1 text-xs text-zinc-700 dark:border-zinc-800 dark:bg-zinc-800/60 dark:text-zinc-300"
>
{file.type === "image" ? <ImageIcon /> : <FileIcon />}
<span className="max-w-40 truncate">{file.name}</span>
{file.size && <span className="text-zinc-500 dark:text-zinc-400">{file.size}</span>}
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => onRemoveAttachment?.(file.id)}
className="rounded p-0.5 text-zinc-400 transition-colors hover:bg-zinc-200 hover:text-zinc-700 dark:hover:bg-zinc-700 dark:hover:text-zinc-200"
>
<XIcon />
</button>
</span>
))}
</div>
)}
{/* Textarea */}
<textarea
ref={textareaRef}
rows={1}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
disabled={disabled}
aria-label="Prompt"
className="block w-full resize-none bg-transparent px-4 pt-3.5 pb-1.5 text-[15px] leading-6 text-zinc-900 outline-none placeholder:text-zinc-400 dark:text-zinc-100 dark:placeholder:text-zinc-500"
/>
{/* Toolbar */}
<div className="flex items-center gap-1 px-2.5 pb-2.5">
<button
type="button"
onClick={onAttach}
disabled={disabled}
aria-label="Add attachment"
className={iconButton}
>
<PlusIcon />
</button>
{showWebSearch && (
<button
type="button"
onClick={() => setWebSearch((v) => !v)}
disabled={disabled}
aria-pressed={webSearch}
aria-label="Web search"
className={toggleButton(webSearch)}
>
<GlobeIcon />
<span className="hidden sm:inline">Search</span>
</button>
)}
{showTools && (
<button
type="button"
onClick={() => setTools((v) => !v)}
disabled={disabled}
aria-pressed={tools}
aria-label="Tools"
className={toggleButton(tools)}
>
<WrenchIcon />
<span className="hidden sm:inline">Tools</span>
</button>
)}
{/* Model selector */}
{models && models.length > 0 && (
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setModelOpen((v) => !v)}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={modelOpen}
className="inline-flex h-8 items-center gap-1 rounded-lg px-2.5 text-xs font-medium text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 disabled:opacity-40 disabled:pointer-events-none dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
>
{activeModel?.icon}
{activeModel?.name ?? "Model"}
<ChevronDownIcon className={modelOpen ? "rotate-180 transition-transform" : "transition-transform"} />
</button>
{modelOpen && (
<div
role="listbox"
className="absolute bottom-full left-0 z-20 mb-2 w-56 overflow-hidden rounded-xl border border-zinc-200 bg-white p-1 shadow-lg dark:border-zinc-800 dark:bg-zinc-900"
>
{models.map((m) => (
<button
key={m.id}
type="button"
role="option"
aria-selected={m.id === model}
onClick={() => {
setModel(m.id);
setModelOpen(false);
textareaRef.current?.focus();
}}
className={`flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800 ${
m.id === model ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-600 dark:text-zinc-400"
}`}
>
<span className="flex min-w-0 items-center gap-1.5">
{m.icon}
<span className="truncate">{m.name}</span>
</span>
{m.hint && <span className="shrink-0 text-xs text-zinc-500 dark:text-zinc-400">{m.hint}</span>}
</button>
))}
</div>
)}
</div>
)}
<div className="flex-1" />
<button
type="button"
onClick={onVoice}
disabled={disabled}
aria-label="Voice input"
className={iconButton}
>
<MicIcon />
</button>
{loading ? (
<button
type="button"
onClick={onStop}
aria-label="Stop generating"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 text-white transition-opacity hover:opacity-80 dark:bg-zinc-100 dark:text-zinc-900"
>
<StopIcon />
</button>
) : (
<button
type="button"
onClick={submit}
disabled={!canSubmit}
aria-label="Send message"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 text-white transition-opacity hover:opacity-80 disabled:opacity-30 dark:bg-zinc-100 dark:text-zinc-900"
>
<ArrowUpIcon />
</button>
)}
</div>
</div>
{/* Error */}
{error && (
<p role="alert" className="mt-2 flex items-center gap-1.5 px-1 text-xs text-red-600 dark:text-red-400">
<AlertIcon />
{error}
</p>
)}
</div>
);
}
Usage Guidelines
- Send on Enter, newline on Shift+Enter — never the reverse.
- Keep the send button disabled (not hidden) while the input is empty so its position is stable.
- Swap send for a stop button while generating — one target, two states.
- Preserve the draft on error. Losing a long prompt is the worst possible failure.
- Cap the auto-grow height (~5–6 lines) so long prompts never push the conversation off screen.
Common UX Mistakes
- Hiding the model selector behind settings — model choice belongs at the point of prompting.
- Showing a spinner without a way to cancel a generation.
- Clearing attachments after send without telling the user they were included.
- Letting the textarea grow without bound and cover the whole viewport.
Related Components
Prompt Input + Attachments
Prompt input with file attachment chips, upload progress and removal.
Prompt Input + Model Selector
Prompt input with an inline model picker and capability badges.
Streaming Message
Assistant message that renders token-by-token with a cursor and stop control.
User Message
The user's turn — right-aligned bubble with copy, edit and regenerate actions.