Chat

Chat Composer

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.

Free, MIT licenseOpen in Browse
"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 }

Installation

pnpm dlx shadcn add @01orbital/chat-composer
npx shadcn add @01orbital/chat-composer
yarn shadcn add @01orbital/chat-composer
bunx --bun shadcn add @01orbital/chat-composer

Free 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.

Usage

"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}
    />
  )
}

API reference

Prop

Type

Dependencies

npm: @phosphor-icons/react, cn. The CLI installs them for you.

Registry pieces it installs with it:

  • input-group