Inputs

Prompt Input

An autosizing chat input with attachments, streaming stop/send, IME-safe submit and a character counter.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ArrowUp, FileIcon, Paperclip, Square, X } from "lucide-react"
import { cn } from "@/lib/utils"

export type PromptInputStatus = "idle" | "streaming"

export interface PromptInputProps
  extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange" | "onSubmit"> {
  /** Controlled text — the component never owns it. */
  value: string
  onValueChange: (value: string) => void
  /** Fired on submit with the current text and attachments; the component never clears either. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/prompt-input.json

Prompt

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 Entere.nativeEvent.isComposing gates 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 status prop, instead of two separately wired controls.
  • Controlled-with-fallback attachmentsfiles/onFilesChange behave like value/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 URLsuseState(() => 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.

On This Page