The box at the bottom of a chat. It grows as you type, takes files, picks a model, listens, and turns send into stop while a reply is written.
pnpm dlx shadcn@latest add @latheui/prompt-boxFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add lucide-react motionAdd the Lathe items this component imports.
lib/motion.tshooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { ArrowUpIcon, ChevronDownIcon, FileIcon, MicIcon, PlusIcon, SquareIcon, XIcon } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { duration, ease, useMotionSafe } from "@/lib/motion"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export type PromptModel = { id: string; label: string; description?: string }
export type PromptSubmission = { text: string; files: File[]; model?: string }
export type PromptBoxProps = Omit<React.ComponentProps<"form">, "onSubmit" | "children"> & {
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
/** Sends the prompt. The box clears afterwards. */
onSubmit?: (submission: PromptSubmission) => void
placeholder?: string
/** Models to choose from. The picker is hidden when there are none. */
models?: PromptModel[]
model?: string
defaultModel?: string
onModelChange?: (model: string) => void
/** Shows the attach button. Attached files are sent with the prompt. */
attachments?: boolean
/** File types the attach button accepts, as for an input's accept attribute. */
accept?: string
/** Shows a microphone button. Called with true when listening starts and false when it stops. */
onVoice?: (listening: boolean) => void
/** While a reply is being written: the send button becomes a stop button. */
busy?: boolean
onStop?: () => void
disabled?: boolean
/** Accessible name of the text field. */
label?: string
}
/** Lines the field grows to before it scrolls. */
const MAX_LINES = 8
/**
* The box at the bottom of a chat: a field that grows with what you type, files, a model
* picker, voice and a send button that turns into stop while a reply is written.
*/
function PromptBox({
value: valueProp,
defaultValue = "",
onValueChange,
onSubmit,
placeholder = "Ask anything…",
models = [],
model: modelProp,
defaultModel,
onModelChange,
attachments = true,
accept,
onVoice,
busy = false,
onStop,
disabled = false,
label = "Message",
className,
...props
}: PromptBoxProps) {
const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange })
const [model, setModel] = useControllableState({
value: modelProp,
defaultValue: defaultModel ?? models[0]?.id ?? "",
onChange: onModelChange,
})
const [files, setFiles] = React.useState<File[]>([])
const [listening, setListening] = React.useState(false)
const field = React.useRef<HTMLTextAreaElement>(null)
const picker = React.useRef<HTMLInputElement>(null)
const { reduced, safe } = useMotionSafe()
const canSend = !disabled && !busy && (value.trim().length > 0 || files.length > 0)
const current = models.find((m) => m.id === model)
// Grow with the text, up to MAX_LINES, then scroll.
React.useLayoutEffect(() => {
const node = field.current
if (!node) return
node.style.height = "auto"
const line = parseFloat(getComputedStyle(node).lineHeight) || 24
node.style.height = `${Math.min(node.scrollHeight, line * MAX_LINES)}px`
}, [value])
function send() {
if (!canSend) return
onSubmit?.({ text: value.trim(), files, model: current?.id })
setValue("")
setFiles([])
field.current?.focus()
}
function toggleVoice() {
const next = !listening
setListening(next)
onVoice?.(next)
}
const iconButton =
"grid size-8 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4"
return (
<form
data-slot="prompt-box"
data-busy={busy || undefined}
onSubmit={(event) => {
event.preventDefault()
send()
}}
className={cn(
"flex w-full flex-col gap-2 rounded-[1.4rem] border border-border bg-card p-2.5 shadow-[0_1px_2px_rgb(0_0_0/0.05),0_8px_24px_-12px_rgb(0_0_0/0.12)] transition-[border-color,box-shadow] duration-150 focus-within:border-ring/50 focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--ring)_14%,transparent)]",
disabled && "opacity-60",
className
)}
{...props}
>
<AnimatePresence initial={false}>
{files.length > 0 && (
<motion.ul
aria-label="Attached files"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={safe({ duration: duration.base, ease: ease.out })}
className="flex flex-wrap gap-1.5 overflow-hidden px-1 pt-1"
>
{files.map((file, index) => (
<li
key={`${file.name}-${index}`}
className="flex h-8 max-w-56 items-center gap-2 rounded-lg border border-border bg-background pr-1 pl-2.5 text-[13px]"
>
<FileIcon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{file.name}</span>
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => setFiles((all) => all.filter((_, i) => i !== index))}
className="grid size-6 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<XIcon aria-hidden="true" className="size-3.5" />
</button>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
<textarea
ref={field}
rows={1}
aria-label={label}
placeholder={placeholder}
value={value}
disabled={disabled}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
// Enter sends, Shift+Enter starts a new line, and an IME composition is left alone.
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault()
send()
}
}}
className="max-h-none min-h-6 w-full resize-none bg-transparent px-2 pt-1.5 text-[15px] leading-6 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
/>
<div className="flex items-center gap-1">
{attachments && (
<>
<button
type="button"
aria-label="Attach files"
disabled={disabled}
onClick={() => picker.current?.click()}
className={iconButton}
>
<PlusIcon aria-hidden="true" />
</button>
<input
ref={picker}
type="file"
multiple
accept={accept}
tabIndex={-1}
aria-hidden="true"
className="sr-only"
onChange={(event) => {
const picked = Array.from(event.target.files ?? [])
setFiles((all) => [...all, ...picked])
event.target.value = ""
}}
/>
</>
)}
<div className="ml-auto flex items-center gap-1">
{models.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled}
aria-label={`Model: ${current?.label ?? "none"}`}
className="flex h-8 cursor-pointer items-center gap-1 rounded-full px-3 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:bg-accent data-[state=open]:text-foreground"
>
{current?.label}
<ChevronDownIcon aria-hidden="true" className="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-56">
<DropdownMenuLabel>Model</DropdownMenuLabel>
<DropdownMenuRadioGroup value={model} onValueChange={setModel}>
{models.map((option) => (
<DropdownMenuRadioItem key={option.id} value={option.id} className={cn(option.description && "h-auto py-1.5")}>
<span className="flex flex-col">
<span>{option.label}</span>
{option.description && (
<span className="text-xs text-muted-foreground">{option.description}</span>
)}
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
{onVoice && (
<button
type="button"
aria-label={listening ? "Stop dictation" : "Dictate"}
aria-pressed={listening}
disabled={disabled}
onClick={toggleVoice}
className={cn(iconButton, "relative", listening && "text-foreground")}
>
{listening && !reduced && (
<span aria-hidden="true" className="absolute inset-0 animate-ping rounded-full bg-destructive/25" />
)}
<MicIcon aria-hidden="true" className={cn("relative", listening && "text-destructive")} />
</button>
)}
{busy ? (
<button
type="button"
aria-label="Stop"
onClick={onStop}
className="grid size-8 shrink-0 cursor-pointer place-items-center rounded-full bg-foreground text-background outline-none transition-transform focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-95"
>
<SquareIcon aria-hidden="true" className="size-3 fill-current" />
</button>
) : (
<button
type="submit"
aria-label="Send"
disabled={!canSend}
className="grid size-8 shrink-0 cursor-pointer place-items-center rounded-full bg-foreground text-background outline-none transition-[opacity,transform] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-95 disabled:cursor-not-allowed disabled:opacity-30"
>
<ArrowUpIcon aria-hidden="true" className="size-4" />
</button>
)}
</div>
</div>
</form>
)
}
export { PromptBox }Depends on
Also accepts native form attributes other than onSubmit.
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | The text, when controlled. |
| defaultValue | string | "" | Starting text, when uncontrolled. |
| onValueChange | (value: string) => void | — | Called as the text changes. |
| onSubmit | (submission: { text, files, model }) => void | — | Sends the prompt. The box clears and keeps focus afterwards. |
| placeholder | string | "Ask anything…" | Shown while empty. |
| models | { id: string; label: string; description?: string }[] | [] | Models for the picker. With none, the picker is hidden. |
| model | string | — | The selected model id, when controlled. |
| defaultModel | string | — | Starting model. The first one when left out. |
| onModelChange | (model: string) => void | — | Called when a model is picked. |
| attachments | boolean | true | Shows the attach button. |
| accept | string | — | File types to accept, as for a file input. |
| onVoice | (listening: boolean) => void | — | Shows a microphone button. Called with true when dictation starts and false when it stops. Wire it to your speech recognition. |
| busy | boolean | false | A reply is being written: send becomes stop. |
| onStop | () => void | — | Called by the stop button. |
| disabled | boolean | false | Disables the field and every button. |
| label | string | "Message" | Accessible name of the text field. |
<PromptBox
attachments={false}
placeholder="Ask about this page"
className="rounded-xl p-2"
onSubmit={({ text }) => ask(text)}
/>