Skip to content
goodtabs UI
BrowseBrowse components

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

0

An 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

PropTypeDefaultDescription
valuestringControlled text. Leave out for an uncontrolled composer.
defaultValuestring""Starting text for the uncontrolled form.
onValueChange(value: string) => voidFires with the new text on every change and with an empty string after a send.
onSubmit(text: string) => voidCalled with the trimmed text when send is clicked or Cmd or Ctrl plus Enter is pressed.
maxLengthnumberHard limit on characters, shown next to the count.
placeholderstring"Write a message"Placeholder for the empty field.
sendLabelstring"Send"Label of the send button.
leadingReactNodeSlot at the left of the bottom bar, for an attach button or a hint.
maxRowsnumber8Rows the field grows to before it scrolls.
disabledbooleanfalseDisables 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