Source: https://monarchui.dev/components/message-composer/

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.

@rookcom/monarch-react/editor

Component

## Chat message with reply and mentions

## Usage examples

### Ticket reply and internal note

### Editing, attachments and disabled

## 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

```tsx
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 in `attachments`.
- `emoji(insert)` renders the product’s picker; `insert` puts the emoji at the caret.
- Arrow Up in an empty box calls `onEditLast` (edit your last message).
- `onTyping` fires at most once per `typingIntervalMs` while the text changes; `onTypingStop` fires 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

label

names the text box. The mode switch is named by

messages.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-subtle` and `--mn-color-warning-border`, the reply context `--mn-color-bg-subtle` with an `--mn-color-accent-border` edge.
- 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 `onDraftChange` and restore them with `defaultValue`.

### 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](https://monarchui.dev/components/rich-text-editor/): The editor underneath, for documents and long descriptions.
- [MessageRow](https://monarchui.dev/components/message-row/): Shows the messages this composer sends.
- [FileUpload](https://monarchui.dev/components/file-upload/): Upload state for the attachment row.
- [MentionPicker](https://monarchui.dev/components/mention-picker/): Mentions in a plain textarea composer.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react` |
| Monarch components | [Button](https://monarchui.dev/components/button/), [SegmentedControl](https://monarchui.dev/components/segmented-control/), [RichTextEditor](https://monarchui.dev/components/rich-text-editor/) |

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-enter` mode; 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 reach `onFiles`; 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, `onEditLast` on 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`

## All example code

### Chat message with reply and mentions

```tsx
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>
```

### Ticket reply and internal note

```tsx
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: customer@example.com' : '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

```tsx
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>
```
