ChatComposer
A message box that is a real form, with a send button that says whether it will do anything.
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
npm i mihirmodi-ui motionThe 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
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
| Prop | Type | Notes |
|---|---|---|
| value / onChangestring / (v) => void | string / (v) => void | Controlled. The composer never owns the text. |
| onSubmit(value) => void | (value) => void | Enter or the button. Never called with an empty or whitespace-only value. |
| busyboolean | boolean= false | Disables the field; the placeholder becomes `busyText`. |
| toolsReactNode | ReactNode | Controls bottom-left, before the hint — an attach button. |
| headerReactNode | ReactNode | Above the field — attachment chips, an upload zone. |
| surface"glass" | "plain" | "glass" | "plain"= "glass" | Glass floats over scrolling content; plain sits inside a panel of its own. |
| onKeyDown(e) => void | (e) => void | Runs 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.
"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>
);
});