← All components

ChatComposer

A message box that is a real form, with a send button that says whether it will do anything.

Enter to send

The idea

Enter-to-send is a convenience laid over a form with a real submit button, so a screen reader, a password manager or a phone keyboard's go key all find something to press. The send button is ink when there is something to send and a quiet grey when there is not, which answers the question before you try. Glass for a composer floating over a transcript; plain for one inside a panel.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

ChatComposer — usage17 lines
import { useState } from "react";
import { ChatComposer } from "mihirmodi-ui";

const [value, setValue] = useState("");

<ChatComposer
  value={value}
  onChange={setValue}
  onSubmit={(text) => { send(text); setValue(""); }}
  busy={isAnswering}
  placeholder="Ask about the quarter"
  tools={<AttachButton />}
  // Claim keys before Enter is handled — 1–3 for suggested follow-ups.
  onKeyDown={(e) => {
    if (!value && /^[1-3]$/.test(e.key)) { e.preventDefault(); send(followUps[+e.key - 1]); }
  }}
/>

Props

PropNotes
value / onChangestring / (v) => voidControlled. The composer never owns the text.
onSubmit(value) => voidEnter or the button. Never called with an empty or whitespace-only value.
busybooleanDisables the field; the placeholder becomes `busyText`.
toolsReactNodeControls bottom-left, before the hint — an attach button.
headerReactNodeAbove the field — attachment chips, an upload zone.
surface"glass" | "plain"Glass floats over scrolling content; plain sits inside a panel of its own.
onKeyDown(e) => voidRuns before Enter is handled. `preventDefault()` to claim a key — number keys for follow-ups.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/ChatComposer.tsx119 lines
"use client";

import { forwardRef, type ReactNode } from "react";
import { SendIcon } from "../lib/glyph.js";

export type ChatComposerProps = {
  value: string;
  onChange: (value: string) => void;
  /** Enter sends; Shift+Enter is a new line. Not called for an empty or whitespace-only value. */
  onSubmit: (value: string) => void;
  placeholder?: string;
  /** The small line bottom-left — "Enter to send", a scope note. */
  hint?: ReactNode;
  /** While true the field is disabled and the placeholder says so. */
  busy?: boolean;
  busyText?: string;
  /** Controls to the left of the hint — an attach button. */
  tools?: ReactNode;
  /** Above the field — attachment chips, an upload zone. */
  header?: ReactNode;
  /**
   * `glass` floats over content that scrolls under it — the bottom of a
   * transcript. `plain` sits on a surface of its own, inside a panel.
   */
  surface?: "glass" | "plain";
  rows?: number;
  /** Focus the field when it mounts — for a composer that appears on demand. */
  autoFocus?: boolean;
  /** Extra keys, before Enter is handled. Call `preventDefault()` to claim one. */
  onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
  className?: string;
  "aria-label"?: string;
};

/**
 * The box you type a message into.
 *
 * It is a form with a real submit button, so Enter-to-send is a convenience on
 * top of something that already works — a screen reader, a password manager or
 * a mobile keyboard's "go" key all find the button. The send button is ink when
 * there is something to send and a quiet grey when there is not, so it says
 * whether pressing it will do anything before you try.
 */
export const ChatComposer = forwardRef<HTMLTextAreaElement, ChatComposerProps>(function ChatComposer(
  {
    value,
    onChange,
    onSubmit,
    placeholder = "Ask anything",
    hint = "Enter to send",
    busy = false,
    busyText = "Working…",
    tools,
    header,
    surface = "glass",
    rows = 2,
    autoFocus,
    onKeyDown,
    className = "",
    "aria-label": ariaLabel = "Message",
  },
  ref
) {
  const can = value.trim().length > 0 && !busy;
  const send = () => can && onSubmit(value.trim());

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        send();
      }}
      className={className}
    >
      <div
        className={`rounded-2xl border-[0.5px] border-gray-alpha-400 p-1.5 ${
          surface === "glass" ? "glass-nav dock-shadow" : "bg-background-100"
        }`}
      >
        {header && <div className="px-1.5 pt-1.5">{header}</div>}
        <textarea
          ref={ref}
          rows={rows}
          autoFocus={autoFocus}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          onKeyDown={(e) => {
            onKeyDown?.(e);
            if (e.defaultPrevented) return;
            if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
              e.preventDefault();
              send();
            }
          }}
          placeholder={busy ? busyText : placeholder}
          disabled={busy}
          aria-label={ariaLabel}
          className="w-full resize-none bg-transparent px-3 pb-1 pt-2.5 text-[14px] leading-[1.5] text-gray-1000 outline-none placeholder:text-faint disabled:opacity-60"
        />
        <div className="flex items-center justify-between gap-2 px-1.5 pb-0.5">
          <div className="flex min-w-0 items-center gap-2">
            {tools}
            {hint && <span className="truncate text-[12px] text-faint">{hint}</span>}
          </div>
          <button
            type="submit"
            disabled={!can}
            aria-label="Send"
            className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] transition-[background-color,color,transform] duration-150 ease-geist active:scale-[0.92] ${
              can ? "bg-ink text-background-100" : "bg-gray-200 text-faint"
            }`}
          >
            <SendIcon />
          </button>
        </div>
      </div>
    </form>
  );
});