Prompt Input + Model Selector UI
Prompt input with an inline model picker and capability badges.
promptinputmodelselector
Best mix of speed and quality for daily work.
Props
Also takes onSubmit, onChange.
Component source
Single-file React + Tailwind component. No dependencies — drop it into any project with Tailwind configured.
prompt-input-model-selector.tsx
"use client";
import * as React from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type ModelOption = {
id: string;
name: string;
description?: string;
badges?: 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 PromptInputModelSelectorProps = {
models: ModelOption[];
defaultModel?: string;
placeholder?: string;
disabled?: boolean;
onSubmit?: (value: string, modelId: string) => void;
onChange?: (modelId: string) => void;
className?: string;
};
/* ------------------------------------------------------------------ */
/* Icons */
/* ------------------------------------------------------------------ */
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 ChevronDownIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="13" height="13" {...props}>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
function CheckIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" width="13" height="13" {...props}>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
function SparkIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="13" height="13" {...props}>
<path d="M12 3v2M12 19v2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M3 12h2M19 12h2M6 7l-1-1M18 17l1 1M12 8l1.5 3.5L17 13l-3.5 1.5L12 18l-1.5-3.5L7 13l3.5-1.5Z" />
</svg>
);
}
function BoltIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="13" height="13" {...props}>
<path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
);
}
/* ------------------------------------------------------------------ */
/* PromptInputModelSelector */
/* ------------------------------------------------------------------ */
export function PromptInputModelSelector({
models,
defaultModel,
placeholder = "Ask anything…",
disabled = false,
onSubmit,
onChange,
className = "",
}: PromptInputModelSelectorProps) {
const [value, setValue] = React.useState("");
const [modelId, setModelId] = React.useState(defaultModel ?? models[0]?.id ?? "");
const [open, setOpen] = React.useState(false);
const menuRef = React.useRef<HTMLDivElement>(null);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const active = models.find((m) => m.id === modelId);
const canSubmit = value.trim().length > 0 && !disabled;
React.useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "0px";
el.style.height = `${Math.min(el.scrollHeight, 160)}px`;
}, [value]);
React.useEffect(() => {
if (!open) return;
function onClick(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
function selectModel(id: string) {
if (id === modelId) return;
setModelId(id);
onChange?.(id);
setOpen(false);
textareaRef.current?.focus();
}
function submit() {
if (!canSubmit) return;
onSubmit?.(value.trim(), modelId);
setValue("");
}
return (
<div className={`w-full ${className}`}>
{/* axe reports the dimmed model hint here as a 2.34:1 contrast failure.
Leave it. WCAG 2.1 SC 1.4.3 exempts "text that is part of an inactive
user interface component" from the contrast minimum, and axe cannot
see that this subtree is disabled. Darkening it would make the
disabled state read as enabled — a real regression in service of a
misapplied rule. */}
<div
className={`rounded-2xl border bg-white shadow-sm transition-colors dark:bg-zinc-900 ${
disabled
? "border-zinc-200 opacity-60 dark:border-zinc-800"
: "border-zinc-200 focus-within:border-zinc-400 dark:border-zinc-800 dark:focus-within:border-zinc-600"
}`}
>
{/* Model bar */}
<div className="flex items-center gap-2 border-b border-zinc-100 px-2.5 pt-2 pb-2 dark:border-zinc-800">
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
className="inline-flex h-7 items-center gap-1.5 rounded-lg bg-zinc-100 px-2.5 text-xs font-medium text-zinc-700 transition-colors hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
>
<SparkIcon className="text-zinc-400" />
{active?.icon}
{active?.name ?? "Select model"}
<ChevronDownIcon className={open ? "rotate-180 transition-transform text-zinc-400" : "text-zinc-400 transition-transform"} />
</button>
{open && (
<div
role="listbox"
className="absolute left-0 top-full z-20 mt-1.5 w-72 overflow-hidden rounded-xl border border-zinc-200 bg-white p-1 shadow-lg dark:border-zinc-700 dark:bg-zinc-800"
>
{models.map((m) => (
<button
key={m.id}
type="button"
role="option"
aria-selected={m.id === modelId}
onClick={() => selectModel(m.id)}
className={`flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2.5 text-left transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-700 ${
m.id === modelId ? "bg-zinc-50 dark:bg-zinc-700/60" : ""
}`}
>
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center text-zinc-400">
{m.id === modelId ? <CheckIcon className="text-emerald-500" /> : <BoltIcon />}
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5 text-sm font-medium text-zinc-800 dark:text-zinc-100">
{m.icon}
{m.name}
{m.badges?.map((b) => (
<span
key={b}
className="rounded bg-zinc-100 px-1 py-0.5 text-[10px] font-medium text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300"
>
{b}
</span>
))}
</span>
{m.description && (
<span className="mt-0.5 block text-xs leading-4 text-zinc-500 dark:text-zinc-400">
{m.description}
</span>
)}
</span>
</button>
))}
</div>
)}
</div>
{active?.description && (
<span className="hidden truncate text-xs text-zinc-500 dark:text-zinc-400 sm:block">{active.description}</span>
)}
</div>
{/* Input */}
<textarea
ref={textareaRef}
rows={1}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
placeholder={placeholder}
disabled={disabled}
aria-label="Prompt"
className="block w-full resize-none bg-transparent px-4 pt-3 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"
/>
<div className="flex items-center justify-end px-2.5 pb-2.5">
<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>
</div>
);
}
Usage Guidelines
- Put model choice where the prompt is composed, not in a settings page — users switch per message.
- Describe the trade-off, not just the brand — 'fast' vs 'most capable' is the real decision.
- Keep one badge per model; a list of six badges is indistinguishable from no badges.
- Persist the last-used model but never assume it — surface it clearly in the selector.
- Don't reload or lose the draft when switching models.
Common UX Mistakes
- Burying model selection in settings so users keep asking the wrong model.
- Showing only model names with no guidance — non-experts can't choose between 'Pro' and 'Ultra'.
- Resetting the composer when the model changes.
- Auto-switching models based on prompt content without telling the user.
Related Components
Prompt Input
The core input for AI chat products — with attachments, model selector, tools, voice and loading states.
Prompt Input + Attachments
Prompt input with file attachment chips, upload progress and removal.
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.