# AI Chat Input

> AI chat composer with auto-grow, attachments, model picker and Enter to send.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/ai-chat-input
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/ai-chat-input.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/ai-chat-input-demo.tsx"
"use client"

import * as React from "react"

import { AIChatInput, type ChatSubmission } from "@/components/ai-chat-input"

export default function AIChatInputDemo() {
  const [last, setLast] = React.useState<ChatSubmission | null>(null)
  return (
    <div className="flex w-full max-w-xl flex-col gap-4">
      <p className="text-center text-2xl font-semibold tracking-tight">What can I help you build?</p>
      <AIChatInput onSubmit={setLast} />
      <p className="min-h-5 text-center text-xs text-muted-foreground">
        {last
          ? `Sent to ${last.model}: "${last.message}"`
          : "Press Enter to send, Shift + Enter for a new line"}
      </p>
    </div>
  )
}
```

## Source

```tsx title="components/ai-chat-input.tsx"
"use client"

import * as React from "react"
import { ArrowUp, Paperclip, Square, X } from "lucide-react"

import { cn } from "@/lib/utils"

export type ChatSubmission = { message: string; model: string; files: File[] }

export interface AIChatInputProps extends Omit<React.ComponentProps<"form">, "onSubmit"> {
  onSubmit: (submission: ChatSubmission) => void
  models?: string[]
  placeholder?: string
  /** Shows a stop button instead of send while a response streams */
  loading?: boolean
  onStop?: () => void
  /** Max textarea height in px before it scrolls */
  maxHeight?: number
}

export function AIChatInput({
  onSubmit,
  models = ["Claude Opus 5", "Claude Sonnet 5", "Claude Haiku 4.5"],
  placeholder = "Ask anything…",
  loading = false,
  onStop,
  maxHeight = 200,
  className,
  ...props
}: AIChatInputProps) {
  const [message, setMessage] = React.useState("")
  const [model, setModel] = React.useState(models[0])
  const [files, setFiles] = React.useState<File[]>([])
  const textareaRef = React.useRef<HTMLTextAreaElement>(null)
  const fileRef = React.useRef<HTMLInputElement>(null)

  React.useLayoutEffect(() => {
    const el = textareaRef.current
    if (!el) return
    el.style.height = "auto"
    el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`
  }, [message, maxHeight])

  const canSend = message.trim().length > 0 && !loading

  const submit = () => {
    if (!canSend) return
    onSubmit({ message: message.trim(), model, files })
    setMessage("")
    setFiles([])
  }

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        submit()
      }}
      className={cn(
        "w-full rounded-2xl border bg-card text-card-foreground shadow-sm transition-shadow focus-within:shadow-md focus-within:ring-1 focus-within:ring-ring/40",
        className
      )}
      {...props}
    >
      {files.length > 0 && (
        <ul className="flex flex-wrap gap-2 px-3 pt-3">
          {files.map((file, i) => (
            <li key={i} className="inline-flex items-center gap-1.5 rounded-lg border bg-muted px-2 py-1 text-xs">
              <span className="max-w-40 truncate">{file.name}</span>
              <button
                type="button"
                aria-label={`Remove ${file.name}`}
                onClick={() => setFiles((all) => all.filter((_, j) => j !== i))}
                className="text-muted-foreground hover:text-foreground"
              >
                <X className="size-3" />
              </button>
            </li>
          ))}
        </ul>
      )}
      <textarea
        ref={textareaRef}
        rows={1}
        value={message}
        placeholder={placeholder}
        onChange={(e) => setMessage(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
            e.preventDefault()
            submit()
          }
        }}
        className="block w-full resize-none bg-transparent px-4 pt-4 pb-2 text-sm outline-none placeholder:text-muted-foreground"
      />
      <div className="flex items-center gap-2 px-3 pb-3">
        <input
          ref={fileRef}
          type="file"
          multiple
          hidden
          onChange={(e) => {
            const picked = Array.from(e.target.files ?? [])
            setFiles((all) => [...all, ...picked])
            e.target.value = ""
          }}
        />
        <button
          type="button"
          aria-label="Attach files"
          onClick={() => fileRef.current?.click()}
          className="inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
        >
          <Paperclip className="size-4" />
        </button>
        <select
          aria-label="Model"
          value={model}
          onChange={(e) => setModel(e.target.value)}
          className="h-8 rounded-lg bg-transparent px-2 text-xs text-muted-foreground outline-none hover:bg-muted hover:text-foreground"
        >
          {models.map((m) => (
            <option key={m}>{m}</option>
          ))}
        </select>
        {loading ? (
          <button
            type="button"
            aria-label="Stop"
            onClick={onStop}
            className="ml-auto inline-flex size-8 items-center justify-center rounded-full bg-foreground text-background"
          >
            <Square className="size-3 fill-current" />
          </button>
        ) : (
          <button
            type="submit"
            aria-label="Send"
            disabled={!canSend}
            className="ml-auto inline-flex size-8 items-center justify-center rounded-full bg-foreground text-background transition-opacity disabled:opacity-30"
          >
            <ArrowUp className="size-4" />
          </button>
        )}
      </div>
    </form>
  )
}
```
