Chat message form with an auto-growing textarea that stops at a fixed height and scrolls, and a send button that turns into stop while a reply streams. Enter sends, Shift+Enter adds a line.
"use client"
import * as React from "react"
import { ArrowUpIcon, StopIcon } from "@phosphor-icons/react"
import { cn } from "cn"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@/components/ui/input-group"
/**
* The message box under a chat. Enter sends and Shift+Enter adds a line; it
* grows with the text up to six lines, then scrolls. While a reply streams,
* the send button becomes a stop button.
*/
function ChatComposer({
onSend,
onStop,
streaming,
placeholder = "Ask about the kit…",
className,
value: controlledValue,
onValueChange,
}: {
/** Called with the message when the person sends it. The box then clears. */
onSend: (value: string) => void
/** Called when the person presses stop while a reply streams. */
onStop: () => void
/** True while a reply streams: sending pauses and the button stops instead. */
streaming: boolean
/** The hint shown in the empty box. Defaults to `Ask about the kit…`. */
placeholder?: string
/** Extra classes for the outer `form`. */
className?: string
/** The draft, when you control it. Leave it out and the composer keeps its own. */
value?: string
/** Called on every change to the draft. */
onValueChange?: (value: string) => void
}) {
const id = React.useId()
const ref = React.useRef<HTMLTextAreaElement>(null)
const [draft, setDraft] = React.useState("")
const value = controlledValue ?? draft
const setValue = (next: string) => {
if (controlledValue === undefined) setDraft(next)
onValueChange?.(next)
}
// Grow to the content, then stop — a scroll bar past six lines.
React.useEffect(() => {
const node = ref.current
if (!node) return
node.style.height = "0px"
node.style.height = `${Math.min(node.scrollHeight, 144)}px`
}, [value])
const submit = () => {
if (streaming || !value.trim()) return
onSend(value)
setValue("")
}
return (
<form
data-slot="chat-composer"
onSubmit={(event) => {
event.preventDefault()
submit()
}}
className={cn("@container/composer min-w-0 shrink-0", className)}
>
<InputGroup className="border-input bg-transparent @max-[10rem]/composer:flex-wrap dark:bg-input/30">
<label htmlFor={id} className="sr-only">
Message
</label>
<InputGroupTextarea
id={id}
ref={ref}
rows={1}
value={value}
placeholder={placeholder}
enterKeyHint="send"
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
// The IME guard: a candidate selection on Enter must never fire a send.
if (event.nativeEvent.isComposing || event.keyCode === 229) return
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
submit()
}
}}
className="min-h-[calc(1lh+--spacing(5))] min-w-0 py-2.5 ps-4 text-sm/6 @max-[10rem]/composer:basis-full"
/>
<InputGroupAddon
align="inline-end"
className="self-end pb-1.5 @max-[10rem]/composer:ms-auto"
>
<InputGroupButton
data-slot="chat-composer-send"
type={streaming ? "button" : "submit"}
onClick={streaming ? onStop : undefined}
disabled={!streaming && !value.trim()}
aria-label={streaming ? "Stop" : "Send"}
variant="default"
size="icon-sm"
>
{streaming ? (
<StopIcon weight="fill" />
) : (
<ArrowUpIcon weight="light" />
)}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
)
}
export { ChatComposer }
pnpm dlx shadcn add @01orbital/chat-composernpx shadcn add @01orbital/chat-composeryarn shadcn add @01orbital/chat-composerbunx --bun shadcn add @01orbital/chat-composerFree under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.
It lands at components/chat/chat-composer.tsx, as source you own and edit.
"use client"
import * as React from "react"
import { ChatComposer } from "@/components/chat/chat-composer"
export default function ChatComposerDemo() {
const [, setSent] = React.useState<string[]>([])
return (
<ChatComposer
className="w-full max-w-md"
onSend={(value) => setSent((prev) => [...prev, value])}
onStop={() => {}}
streaming={false}
/>
)
}Prop
Type
npm: @phosphor-icons/react, cn. The CLI installs them for you.
Registry pieces it installs with it:
input-groupChat Code Block
Fenced code surface for chat replies, pinned dark in both modes, with a language label and copy button. Lazy-loads Shiki for highlighting and waits until a streamed fence closes.
Chat Conversation
Scrolling chat transcript built on Message Scroller: a polite live log that follows the streaming edge, with turn, attribution, pulsing cursor and shimmering status parts.