Prompt Input
An autosizing chat input with attachments, streaming stop/send, IME-safe submit and a character counter.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/prompt-input.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PromptInput" component (lucide-react
icons, no other dependencies).
Contract
- Export a forwardRef textarea extending
Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange" | "onSubmit">.
- Required: value (string), onValueChange(value), onSubmit(value, files: File[]).
- Optional: status = "idle" | "streaming" (default "idle"), onStop(), maxRows
(default 8), maxLength, files + onFilesChange (controlled attachments —
omit both for an internal uncontrolled list), accept, toolbar (ReactNode,
rendered bottom-left), submitOnEnter (default true), counter (default
false), disabled.
Behavior
- Autosize: measure lineHeight/padding/border once per mount, grow the
textarea to fit content up to maxRows lines, then switch to internal
scrolling. Recompute in a useLayoutEffect keyed on [value, maxRows] that
only writes el.style.height/overflowY — no setState in the effect body.
- Submit keys: Enter submits (unless submitOnEnter is false), Shift+Enter
always inserts a newline, and ⌘/Ctrl+Enter always submits regardless of
submitOnEnter. Every check first tests e.nativeEvent.isComposing — true
means an IME candidate is still being composed, so Enter is left alone
and never submits.
- Send vs Stop: while status is "idle" the trailing button is Send
(ArrowUp), disabled whenever the trimmed value is empty, disabled is
true, or value.length exceeds maxLength. While status is "streaming" the
same slot renders Stop (a filled Square) which calls onStop; the textarea
and attach button both stay fully interactive during streaming.
- Attachments: files/onFilesChange follow the controlled-with-uncontrolled-
fallback pattern (files !== undefined → controlled). Three entry points
feed one addFiles(list) path: the hidden <input type=file> opened by the
paperclip button, onPaste reading e.clipboardData.files (preventDefault
only when files are actually present, so normal text paste is untouched),
and onDragEnter/Over/Leave/Drop on the root with a dragenter/dragleave
depth counter (so passing over nested children doesn't flicker the
highlight) driving a dragActive class. Every incoming file is filtered
through an accept matcher supporting ".ext", "type/*" and exact MIME rules.
- addFiles de-duplicates against the current list on that same identity key.
The file input clears itself so re-picking the same file still fires change,
which means attaching a.png twice would otherwise produce two entries sharing
one React key — and removing either would unmount the wrong thumbnail and
leak the other's object URL. Re-attaching an already-present file is a no-op.
- Thumbnails: each attachment is a list item keyed by
`${file.name}-${file.size}-${file.lastModified}` — stable across
reorders/removals without inventing a synthetic id. Image files get a
useState(() => URL.createObjectURL(file)) lazy initializer (runs exactly
once per mounted instance) plus a useEffect cleanup that revokes it on
removal/unmount; non-image files render an icon + truncated name +
formatted size instead. Every thumbnail has its own remove button.
- Counter: when counter is true, render `${value.length}` (plus
`/{maxLength}` when set), tied to the textarea via aria-describedby; it
turns text-destructive once value.length is within 10% of maxLength or
past it. maxLength never truncates the field itself — it only gates the
Send button and the counter color, so the over-limit state stays
genuinely reachable rather than being blocked at the keystroke.
Rendering & styling
- Semantic tokens only: rounded-2xl border border-input bg-background shell
with focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50;
drag-active swaps to border-primary bg-primary/5 (the color transition is
motion-reduce:transition-none); Send/Stop use bg-primary
text-primary-foreground; the counter's warning state is text-destructive;
disabled drops the shell to opacity-60 and every control to
disabled:opacity-40. cn() merges the consumer className onto the root
shell only — the inner textarea keeps its own fixed classes.
- Accessibility: the textarea's aria-label falls back to placeholder (or
"Prompt input"); every icon button carries its own aria-label (Attach
files, Send message, Stop generating, Remove <filename>); the counter's
id is wired through aria-describedby.
Customization levers
- Density/size: shell padding (p-2), button size (size-8/size-14) and the
rounded-2xl radius are the only geometry knobs — scale them together for
a compact vs. spacious variant.
- Submit policy: submitOnEnter=false turns this into an "always needs
⌘/Ctrl+Enter or the Send button" input for power-user surfaces.
- Toolbar slot: anything goes bottom-left next to the paperclip — model
picker, tool toggles, a token-budget readout — it never touches the
submit/attachment engine.
- Attachment policy: tighten or loosen accept per surface (e.g. "image/*"
only for a vision-only endpoint), or drop files/onFilesChange entirely to
fall back to the internal uncontrolled list.
- Limit UX: pair maxLength with counter for chat-style caps, or omit both
for an unbounded field — the disable logic short-circuits cleanly either
way.Concepts
- IME-safe Enter —
e.nativeEvent.isComposinggates every submit path, so confirming a CJK candidate with Enter never fires a premature send. - Force-submit override — ⌘/Ctrl+Enter always submits regardless of
submitOnEnter, matching the universal "hard send" convention across chat apps. - Status-driven action slot — one trailing button swaps between Send and Stop off a single
statusprop, instead of two separately wired controls. - Controlled-with-fallback attachments —
files/onFilesChangebehave likevalue/onChange: pass both for a controlled list, omit both for a self-contained one. - Content-keyed thumbnails — each attachment's React key is derived from the file itself (name + size + lastModified), so removing one from the middle of the list never hands a stale object URL to a reused component instance.
- Lazy-init object URLs —
useState(() => URL.createObjectURL(file))creates the blob URL exactly once per thumbnail's lifetime, revoked in that same component's unmount cleanup — no shared cache, no leak.
Color Picker
A hex-driven color picker — saturation/value area, hue and alpha sliders, a format-aware text field, quick swatches, and an optional native eyedropper.
Mention Input
A plain-text textarea where a trigger character (default @) opens a caret-anchored, filtered picker — mentions, or repurposed for slash commands.