Composer
A message field that grows as you type, with a character count and a send button.
npx shadcn@latest add https://ui.goodtabs.co/r/composer.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/composer
0
Send is disabled until there is text. Cmd or Ctrl plus Enter sends and clears.
With a limit
71 / 140
Controlled value with maxLength, so the count reads 72 / 140 and stops there.
With an attach button
⌘↵Cmd Enter to send
0An attach button and a keyboard hint in the leading slot of the bottom bar.
Usage
import { Composer } from "@/components/ui/composer"
<Composer placeholder="Reply" maxLength={500} onSubmit={(text) => send(text)} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | Controlled text. Leave out for an uncontrolled composer. | |
| defaultValue | string | "" | Starting text for the uncontrolled form. |
| onValueChange | (value: string) => void | Fires with the new text on every change and with an empty string after a send. | |
| onSubmit | (text: string) => void | Called with the trimmed text when send is clicked or Cmd or Ctrl plus Enter is pressed. | |
| maxLength | number | Hard limit on characters, shown next to the count. | |
| placeholder | string | "Write a message" | Placeholder for the empty field. |
| sendLabel | string | "Send" | Label of the send button. |
| leading | ReactNode | Slot at the left of the bottom bar, for an attach button or a hint. | |
| maxRows | number | 8 | Rows the field grows to before it scrolls. |
| disabled | boolean | false | Disables the field and the send button. |
Source
composer/composer.tsx
"use client";
import * as React from "react";
import { ArrowUpIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
type ComposerProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onSubmit"> & {
/** Controlled text. Leave out for an uncontrolled composer. */
value?: string;
/** Starting text for the uncontrolled form. */
defaultValue?: string;
/** Fires with the new text on every change and after a send clears the field. */
onValueChange?: (value: string) => void;
/** Called with the trimmed text on send. Cmd or Ctrl plus Enter also sends. */
onSubmit?: (text: string) => void;
/** Hard limit on characters. Shown next to the count. */
maxLength?: number;
/** Placeholder for the empty field. */
placeholder?: string;
/** Label of the send button. */
sendLabel?: string;
/** Slot at the left of the bottom bar, for an attach button or a hint. */
leading?: React.ReactNode;
/** Rows the field grows to before it scrolls. */
maxRows?: number;
/** Disables the field and the send button. */
disabled?: boolean;
};
/** Grows the textarea with its content, from one row to maxRows, then scrolls. */
function fit(el: HTMLTextAreaElement, maxRows: number) {
const style = window.getComputedStyle(el);
const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5;
const padding = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom);
const min = lineHeight + padding;
const max = lineHeight * maxRows + padding;
el.style.height = "0px";
const content = el.scrollHeight;
el.style.height = `${Math.min(Math.max(content, min), max)}px`;
el.style.overflowY = content > max ? "auto" : "hidden";
}
/**
* A message composer: an auto-growing field in a bordered surface with a count and a send pill.
* Empty input disables send. Cmd or Ctrl plus Enter sends.
*/
export function Composer({
value,
defaultValue = "",
onValueChange,
onSubmit,
maxLength,
placeholder = "Write a message",
sendLabel = "Send",
leading,
maxRows = 8,
disabled = false,
className,
"aria-label": ariaLabel = "Message",
...props
}: ComposerProps) {
const [inner, setInner] = React.useState(defaultValue);
const controlled = value !== undefined;
const text = controlled ? value : inner;
const ref = React.useRef<HTMLTextAreaElement>(null);
const canSend = text.trim().length > 0 && !disabled;
const update = (next: string) => {
if (!controlled) setInner(next);
onValueChange?.(next);
};
const send = () => {
if (!canSend) return;
onSubmit?.(text.trim());
update("");
ref.current?.focus();
};
React.useLayoutEffect(() => {
if (ref.current) fit(ref.current, maxRows);
}, [text, maxRows]);
return (
<div
data-slot="composer"
data-disabled={disabled || undefined}
className={cn(
"flex w-full flex-col rounded-lg border border-input bg-background transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30 data-disabled:opacity-50",
className,
)}
{...props}
>
<Textarea
ref={ref}
rows={1}
value={text}
onChange={(e) => update(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
send();
}
}}
placeholder={placeholder}
maxLength={maxLength}
disabled={disabled}
aria-label={ariaLabel}
className="field-sizing-fixed min-h-0 resize-none rounded-none border-0 bg-transparent px-3 pt-3 pb-1 shadow-none focus-visible:border-transparent focus-visible:ring-0 disabled:bg-transparent disabled:opacity-100 dark:bg-transparent dark:disabled:bg-transparent"
/>
<div className="flex items-center gap-2 px-2 pt-1 pb-2">
{leading ? <div className="flex min-w-0 items-center gap-1">{leading}</div> : null}
<span className="ml-auto shrink-0 font-mono text-xs text-muted-foreground tabular-nums">
{text.length}
{maxLength !== undefined ? ` / ${maxLength}` : ""}
</span>
<Button type="button" size="sm" onClick={send} disabled={!canSend}>
{sendLabel}
<ArrowUpIcon data-icon="inline-end" aria-hidden="true" />
</Button>
</div>
</div>
);
}
For AI agents
Hand your agent this page, the markdown at /components/composer/prompt.md, or the Copy AI prompt button above.
When to use
- Chat, comment and reply fields at the bottom of a thread
- Short posts and updates with a character limit
- Anywhere a textarea needs a send action and a keyboard shortcut built in
When not to use
- Long form writing: use the auto textarea or an editor
- Forms with several fields and one submit button
- Fields that submit on plain Enter, such as a command line
Agent notes
- Client component that takes functions: a demo or page passing onSubmit or onValueChange must be a client component too
- After a send it clears the field and calls onValueChange with an empty string, so controlled parents clear without extra code
- The leading slot is plain children: pass a ghost icon-sm Button for attach, and a Kbd hint if you want one. kbd is listed as a dependency for that demo
- aria-label names the field and defaults to Message; className goes on the surface, so set widths there