Components · Collaboration
MessageComposer
The chat and ticket reply box: rich text with Enter to send, attachments, emoji and action slots, drafts, edit and reply modes, and a product mode switch.
Chat message with reply and mentions
import { useState } from 'react';
import { MessageComposer, type MessageComposerMessage } from '@rookcom/monarch-react/editor';
import { Button } from '@rookcom/monarch-react';
const PEOPLE = [
{ id: 'p1', label: 'Alex Morgan', description: 'Design' },
{ id: 'p2', label: 'Sam Rivera', description: 'Engineering' },
{ id: 'p3', label: 'Jordan Lee', description: 'Support' },
];
const searchPeople = (text: string) =>
PEOPLE.filter((p) => p.label.toLowerCase().includes(text.toLowerCase()));
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
function ChatDemo() {
const [sent, setSent] = useState<string[]>([]);
const [replyTo, setReplyTo] = useState<{ author: string; excerpt: string } | undefined>({
author: 'Sam Rivera',
excerpt: 'Can we move the design review to Thursday?',
});
return (
<div style={{ display: 'grid', gap: 12, width: '100%' }}>
{sent.length ? (
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 13 }}>
{sent.map((t, i) => (
<li key={i}>{t}</li>
))}
</ul>
) : null}
<MessageComposer
label="Message #design"
placeholder="Message #design"
mentions={{ search: searchPeople }}
replyTo={replyTo}
onDismissReply={() => setReplyTo(undefined)}
emoji={(insert) => (
<Button
size="sm"
variant="ghost"
aria-label="Insert thumbs up"
onClick={() => insert('👍')}
>
👍
</Button>
)}
onSend={async (m: MessageComposerMessage) => {
await wait(600);
setSent((s) => [...s, m.text]);
setReplyTo(undefined);
}}
/>
</div>
);
}
<div style={{ width: '100%', maxWidth: 560 }}>
<ChatDemo />
</div>Usage examples
Ticket reply and internal note
import { useState } from 'react';
import { MessageComposer } from '@rookcom/monarch-react/editor';
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
function TicketDemo() {
const [mode, setMode] = useState('public');
const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
return (
<MessageComposer
label={mode === 'note' ? 'Internal note' : 'Reply to customer'}
modes={[
{ value: 'public', label: 'Reply', placeholder: 'Write a reply to the customer' },
{
value: 'note',
label: 'Internal note',
tone: 'note',
placeholder: 'Only your team can see notes',
sendLabel: 'Add note',
},
]}
mode={mode}
onModeChange={setMode}
modeAside={mode === 'public' ? 'To: [email protected]' : 'Visible to your team'}
sendOn="mod-enter"
onDraftChange={() => {
setStatus('saving');
void wait(400).then(() => setStatus('saved'));
}}
draftStatus={status}
maxLength={2000}
onSend={() => wait(500)}
/>
);
}
<div style={{ width: '100%', maxWidth: 560 }}>
<TicketDemo />
</div>Editing, attachments and disabled
import { MessageComposer } from '@rookcom/monarch-react/editor';
<div style={{ display: 'grid', gap: 16, width: '100%', maxWidth: 560 }}>
<MessageComposer
label="Edit message"
editing
// Editing focuses the text by default; the docs page must not move focus on load.
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={false}
defaultValue="<p>The review is on <strong>Thursday</strong> at 10:00.</p>"
onCancelEdit={() => {}}
onSend={() => {}}
/>
<MessageComposer
label="Message"
placeholder="Add a message"
onFiles={() => {}}
hasAttachments
attachments={
<span
style={{
padding: '4px 8px',
border: '1px solid var(--mn-color-border-subtle)',
borderRadius: 6,
fontSize: 12,
}}
>
quarterly-plan.pdf · 240 KB
</span>
}
onSend={() => {}}
/>
<MessageComposer
label="Message #announcements"
disabled
disabledReason="Only admins can post in #announcements."
onSend={() => {}}
/>
</div>Purpose
Writes and sends a message: a chat message, a thread reply, a ticket reply or an internal note. It wraps RichTextEditor with the send behaviour and the surrounding slots every conversation surface needs.
Use when
- People send messages in a conversation, thread or ticket.
- The same box edits a message that was already sent.
- A reply can be public or an internal note, chosen per message.
Avoid when
- Long-form documents or descriptions with headings: use RichTextEditor.
- A one-line comment with no formatting: a Textarea with a button loads no editor.
- Email campaigns with templates and merge fields: that builder is product-specific.
Design principles
- Sending is the primary action: one obvious button, one key (Enter, or ⌘/Ctrl+Enter where messages are long), and the text is never lost if sending fails.
- The product owns the conversation: uploads, emoji, mentions search, drafts storage, typing presence and modes are callbacks and slots.
- An internal note must never look like a public reply: the note tone changes the box surface, not just a label.
- Compact by default: formatting sits on one row with the actions, so the box is small until someone writes more.
API reference
import { MESSAGE_COMPOSER_TOOLBAR, MessageComposer } from '@rookcom/monarch-react/editor';| Export | Kind | Rendering | Root class |
|---|---|---|---|
MESSAGE_COMPOSER_TOOLBAR | utility | client | – |
MessageComposer | component | client | .mn-composer- |
Renders .mn-message-composer, containing the mode row (__modes), the reply or edit context (__context), the attachment row (__attachments), a RichTextEditor (__editor, toolbar below the text with the actions at its end) and a footer (__footer) with the key hint or disabled reason and the draft status.
editorRef reaches the underlying RichTextEditor handle (focus, insertText, clear, getHTML…).
Exports MESSAGE_COMPOSER_TOOLBAR, the default compact toolbar.
Generated from the TypeScript declarations of packages/react/src/components/collaboration/message-composer.
Properties
onSend(message) receives { html, text, json, mode }. Return a promise for the pending state: the box clears when it resolves and keeps the text when it rejects. Or control the state with sending.
value and defaultValue are HTML (the composer always uses format="html"); restore a saved draft with defaultValue.
onDraftChange(html) runs after typing pauses for draftDebounceMs, on unmount if a save is still waiting, and with "" after a successful send. Show the result with draftStatus.
modes is product data: each mode has a value, label, optional tone: "note", placeholder and send label. The chosen mode is passed to onSend.
MessageComposer
The chat and ticket reply box: a compact RichTextEditor with Enter to send, an attachment row, emoji and action slots, a send button with a pending state, draft persistence, edit and reply-to modes, typing callbacks and a product-supplied mode switch (public reply / internal note).
| Prop | Type | Description |
|---|---|---|
label required | string | Accessible name of the text box, e.g. "Message #design" or "Reply to customer". |
onSend required | (message: MessageComposerMessage) => unknown | Called with the message. Return a promise to show the pending state; the box clears when it resolves and keeps the text when it rejects. |
actions | ReactNode | Extra controls before the send button (schedule, macros, AI assist). |
attachments | ReactNode | The attachment row (uploads in progress, chips), rendered above the text. |
autoFocus | boolean | |
children | ReactNode | |
className | string | |
defaultMode | string | |
defaultValue | string | Initial HTML (a restored draft, or the message being edited). |
disabled | boolean | |
disabledReason | ReactNode | Why the composer is disabled, shown under it and linked to the text box. |
draftDebounceMs | number | Pause before `onDraftChange`. Default 600 ms. |
draftStatus | MessageComposerDraftStatus | Shown under the box: the product's draft save state. |
editing | boolean | Edit an existing message: "Save" label, Escape or Cancel calls `onCancelEdit`. |
editorRef | Ref<RichTextEditorHandle> | The underlying editor's handle (focus, insertText, clear, …). |
emoji | ((insert: (text: string) => void) => ReactNode) | Renders the product's emoji picker; call `insert` with the chosen emoji. |
fileAccept | string | `accept` for the attach button's file picker. |
hasAttachments | boolean | Allows sending with no text, because the attachment row has files. |
id | string | |
maxLength | number | |
mentions | RichTextMentionOptions | |
messages | Partial<MessageComposerMessages> | |
mode | string | |
modeAside | ReactNode | Extra content beside the mode switch, e.g. the audience ("To: customer@…"). |
modes | readonly MessageComposerMode[] | Mode switch (public reply / internal note). Product-supplied. |
onCancelEdit | (() => void) | |
onDismissReply | (() => void) | |
onDraftChange | ((html: string) => void) | Persists the draft. Called after typing pauses, and with `''` after sending. |
onEditLast | (() => void) | Arrow Up in an empty box, e.g. to edit your last message. |
onFiles | RichTextFileHandler | Receives pasted, dropped and picked files. Adds the attach button. |
onModeChange | ((mode: string) => void) | |
onTyping | (() => void) | Someone is typing. Called at most once per `typingIntervalMs` while the text changes. |
onTypingStop | (() => void) | Typing stopped: the box was emptied, sent or left. |
onValueChange | ((html: string) => void) | |
placeholder | string | |
replyTo | MessageComposerReplyTo | The message being answered, shown above the text. |
sending | boolean | Controlled pending state, for products that track sending themselves. |
sendLabel | string | Send button text. Default "Send", or "Save" while `editing`. |
sendOn | "enter" | "mod-enter" | `enter` (default): Enter sends, Shift+Enter adds a line. `mod-enter`: ⌘/Ctrl+Enter sends. |
showCharacterCount | boolean | |
showHint | boolean | Show the send-key hint. Default true. |
toolbar | false | readonly RichTextToolbarItem[] | Formatting controls, or `false` for none. Default `MESSAGE_COMPOSER_TOOLBAR`. |
typingIntervalMs | number | Default 3000 ms. |
value | string | Controlled HTML. |
Variants
| Prop | Values | Description |
|---|---|---|
sendOn | 'enter''mod-enter' | Chat-style (Enter sends, Shift+Enter adds a line) or long-form (Enter adds a line, ⌘/Ctrl+Enter sends). |
modes[].tone | 'default''note' | A note gives the box the warning surface, so private notes are unmistakable. |
editing | 'false''true' | Edit an existing message: "Editing message" context, Save and Cancel, Escape cancels. |
States
| State | Exposed as | Behaviour |
|---|---|---|
| Empty | disabled on the send button | Send is disabled until there is text, or hasAttachments is set. |
| Sending | aria-busy on the send button | The button shows a spinner and "Sending…"; Enter does nothing until it settles. |
| Replying | .mn-message-composer__context | The quoted author and excerpt above the text, with a dismiss button. |
| Editing | data-editing | Editing context, Save and Cancel; the text is focused. |
| Internal note | data-tone="note" | Warning border and surface on the text box. |
| Disabled | data-disabled | The box, toolbar and actions are inert; disabledReason replaces the key hint and is linked to the text box. |
| Draft status | data-status on the status line | "Saving draft…", "Draft saved" or "Draft not saved" (danger colour), in a status region. |
Interaction behaviour
- Enter sends (or ⌘/Ctrl+Enter with
sendOn="mod-enter"); ⌘/Ctrl+Enter always sends. Shift+Enter adds a line break. Inside a code block Enter adds a line, so code can be typed naturally. - While a mention list is open, Enter picks the mention instead of sending.
- The send button and the keys do nothing while sending, so a message is never sent twice.
- Files pasted or dropped into the text, and files from the attach button, go to
onFiles; the product renders them inattachments. emoji(insert)renders the product’s picker;insertputs the emoji at the caret.- Arrow Up in an empty box calls
onEditLast(edit your last message). onTypingfires at most once pertypingIntervalMswhile the text changes;onTypingStopfires when the box is emptied, sent or left.- Changing mode keeps the text; the placeholder and send label follow the mode.
Keyboard support
| Keys | Action |
|---|---|
| Enter | Sends (default), or adds a line with sendOn="mod-enter". |
| ⌘/Ctrl+Enter | Sends. |
| Shift+Enter | Adds a line break. |
| Escape (editing) | Cancels the edit. |
| ArrowUp (empty box) | Calls onEditLast. |
| ArrowLeft / ArrowRight (mode switch) | Changes the mode. |
All RichTextEditor keys apply: formatting shortcuts, the toolbar’s arrow keys and mention suggestions.
Accessibility
- Role
- textbox (multi-line) with a formatting toolbar; radiogroup for modes
- Accessible name
labelnames the text box. The mode switch is named bymessages.modes(default "Message type").
- The key hint ("Enter to send, Shift+Enter for a new line") is linked to the text box with
aria-describedby, so the send key is announced. - A disabled composer links its reason to the text box, so people know why they cannot write.
- Draft status is a polite status region.
- The send button keeps focus while sending (
aria-busy,aria-disabled), so keyboard users are not dropped. - Attach, dismiss-reply and the product’s emoji slot are labelled buttons in the Tab order after the formatting toolbar.
Responsive behaviour
- The toolbar and actions share one row and wrap when the composer is narrow; the send button never shrinks.
- Below 22rem of composer width (a chat dock, a thread panel) the key hint is hidden; it stays available to screen readers through the text box description.
- The text grows from one line to 15rem, then scrolls.
Theme support
- Inherits RichTextEditor’s tokens; the note tone uses
--mn-color-warning-subtleand--mn-color-warning-border, the reply context--mn-color-bg-subtlewith an--mn-color-accent-borderedge. - Light, dark, high-contrast and product accents apply through tokens; reduced motion removes transitions.
Do and don't
Do
- Return the send promise so the composer can show the pending state and keep the text on failure.
- Use
sendOn="mod-enter"where replies are long (tickets, email-like replies), and the default where they are short (chat). - Give internal notes
tone: "note"and a distinct send label ("Add note"). - Persist drafts per conversation with
onDraftChangeand restore them withdefaultValue.
Don't
- Do not clear the box yourself before the send succeeds; return the promise instead.
- Do not hide why a composer is disabled; pass
disabledReason. - Do not put headings or checklists in a chat composer; the default toolbar leaves them out on purpose.
Related components
- RichTextEditor: The editor underneath, for documents and long descriptions.
- MessageRow: Shows the messages this composer sends.
- FileUpload: Upload state for the attachment row.
- MentionPicker: Mentions in a plain textarea composer.
Dependencies
| Kind | Depends on |
|---|---|
| npm packages | react |
| Monarch components | Button, SegmentedControl, RichTextEditor |
Button, IconButton and SegmentedControl for the actions and modes; Tiptap comes through RichTextEditor, so the same optional peers apply.
Adds about 3 KB min+gzip on top of RichTextEditor (127 KB for the entry with both).
Testing coverage
- Enter sends, Shift+Enter adds a line, the box clears;
mod-entermode; Enter in a code block adds a line. - Send disabled while empty; pending state for a promise with no double send; text kept when sending fails.
- Sending only attachments with
hasAttachments; attach button and file picker reachonFiles; emoji slot inserts at the caret. - Draft debounce and the empty draft after sending; typing callbacks throttled and stopped on send.
- Editing (Save, Escape and Cancel), reply-to preview and dismiss,
onEditLaston Arrow Up. - Disabled with a linked reason; modes switch tone, placeholder and send label and reach
onSend; controlled value.
| Check | Covered |
|---|---|
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/message-composer; Cross-browser axe: /components/message-composer |
Tests: packages/react/src/components/collaboration/message-composer