# Message Composer UI

The message composer is a primary interaction surface in chat, so custom layouts should preserve the SDK’s composer behavior for uploads, autocomplete, quoted messages, and submission.

## Best Practices

- Compose custom input UI from exported building blocks instead of duplicating composer state.
- Keep `TextareaComposer` central so autocomplete and slash commands still work.
- Read cooldown through `useCooldownRemaining()` or `CooldownTimer`, not from `MessageComposerContext`.
- Render custom input UI by scoping `WithComponents overrides={{ MessageComposerUI: ... }}` around the target composer subtree.
- Decide whether your custom layout should match the default command-selected state, where the attachment selector and extra actions collapse while `CommandChip` is active.
- Test quoted-message, attachment, link-preview, and voice-recording states together.

You can compose a custom input from the composer building blocks the SDK exports (a few pieces the default `MessageComposerUI` uses internally, such as its additional-actions menu, are not exported — build those regions yourself):

```tsx
import {
  AttachmentPreviewList,
  AudioRecorder,
  CooldownTimer,
  LinkPreviewList,
  QuotedMessagePreview,
  SendButton,
  SimpleAttachmentSelector,
  TextareaComposer,
  WithDragAndDropUpload,
  useCooldownRemaining,
  useMessageComposerContext,
  useMessageComposerController,
  useStateStore,
} from "stream-chat-react";
import type { MessageComposerState } from "stream-chat";

const SendButtonWithCooldown = () => {
  const cooldownRemaining = useCooldownRemaining();
  const { handleSubmit } = useMessageComposerContext();

  return cooldownRemaining ? (
    <CooldownTimer />
  ) : (
    <SendButton sendMessage={handleSubmit} />
  );
};

const editingStateSelector = (state: MessageComposerState) => ({
  editedMessage: state.editedMessage,
});

// When the user edits a message the composer's `editedMessage` is set and the textarea is
// pre-filled automatically. Reproduce an "editing" affordance so the user can see and cancel
// the in-progress edit; a custom layout that omits it leaves them stuck in edit mode.
const EditingIndicator = () => {
  const messageComposer = useMessageComposerController();
  const { editedMessage } = useStateStore(
    messageComposer.state,
    editingStateSelector,
  );

  if (!editedMessage) return null;

  return (
    <div className="editing-indicator">
      <span>Editing message</span>
      {/* `clear()` exits edit mode and resets the composer (public composer API). */}
      <button type="button" onClick={() => messageComposer.clear()}>
        Cancel
      </button>
    </div>
  );
};

const CustomMessageComposer = () => {
  const { recordingController } = useMessageComposerContext();

  return (
    // `WithDragAndDropUpload` restores drag-and-drop file uploads over the composer area.
    <WithDragAndDropUpload className="message-input" component="div">
      {recordingController.recordingState ? (
        // While a voice recording is active the SDK swaps the composer for the recorder.
        // Skip this swap and voice messages silently break.
        <AudioRecorder />
      ) : (
        <>
          <div className="left-container">
            <SimpleAttachmentSelector />
            {/* `recorder` exists only when `audioRecordingEnabled` is set on MessageComposer. */}
            {recordingController.recorder && (
              <button
                type="button"
                onClick={() => recordingController.recorder?.start()}
                title="Record voice message"
              >
                🎙️
              </button>
            )}
          </div>
          <div className="central-container">
            <EditingIndicator />
            <QuotedMessagePreview />
            <AttachmentPreviewList />
            <LinkPreviewList />
            <TextareaComposer />
          </div>
          <div className="right-container">
            <SendButtonWithCooldown />
          </div>
        </>
      )}
    </WithDragAndDropUpload>
  );
};
```

If you want the default slash-command layout too, drive it from composer state rather than toggling a class by hand. The active command lives on the text composer (`messageComposer.textComposer.state.command`); read it with `useStateStore` and apply `str-chat__message-composer--command-active` while it is set. The built-in composer uses that state to collapse the attachment selector and additional action buttons while keeping `CommandChip` visible next to the textarea. `CommandChip` renders nothing when no command is active, so you can render it unconditionally:

```tsx
import {
  CommandChip,
  TextareaComposer,
  useMessageComposerController,
  useStateStore,
} from "stream-chat-react";
import type { TextComposerState } from "stream-chat";

const commandSelector = (state: TextComposerState) => ({
  command: state.command,
});

const TextInputWithCommand = () => {
  const messageComposer = useMessageComposerController();
  const { command } = useStateStore(
    messageComposer.textComposer.state,
    commandSelector,
  );

  const className = command
    ? "str-chat__message-composer str-chat__message-composer--command-active"
    : "str-chat__message-composer";

  return (
    <div className={className}>
      <CommandChip command={command} />
      <TextareaComposer />
    </div>
  );
};
```

Render the custom layout through `MessageComposer` so the SDK still provides the required composer context:

```tsx
import {
  Channel,
  ChannelHeader,
  MessageComposer,
  MessageList,
  Thread,
  WithComponents,
  Window,
} from "stream-chat-react";

const App = () => (
  <Channel>
    <Window>
      <ChannelHeader />
      <MessageList />
      <WithComponents overrides={{ MessageComposerUI: CustomMessageComposer }}>
        {/* `audioRecordingEnabled` is what makes `recordingController.recorder` available. */}
        <MessageComposer audioRecordingEnabled />
      </WithComponents>
    </Window>
    <Thread />
  </Channel>
);
```

## Custom Placeholder

`TextareaComposer` forwards standard `<textarea>` attributes, so pass `placeholder` to replace the default (`"Send a message"`). When matching a reference design, use its exact string:

```tsx
<TextareaComposer placeholder="Message" />
```

The SDK still overrides the placeholder contextually — showing the slow-mode countdown during cooldown and command-specific hints while a command is active — so you get those states for free.

## Custom Message Data

To attach your own fields to the message being composed, write them through the composer's `customDataManager` rather than storing them in component state. It exposes two setters:

- `setMessageData(data)` — merges `data` into the **outgoing message payload** (both the optimistic local message and the request sent to the server).
- `setCustomData(data)` — stores **composer-only** state that is _not_ sent with the message (useful for UI that shouldn't leak into the message).

```tsx
import { useMessageComposerController } from "stream-chat-react";

const PrioritySelector = () => {
  const messageComposer = useMessageComposerController();

  return (
    <button
      type="button"
      onClick={() =>
        messageComposer.customDataManager.setMessageData({ priority: "high" })
      }
    >
      Mark as high priority
    </button>
  );
};
```

Type the extra fields by augmenting `CustomMessageData` so they are recognized on the composed message:

```tsx
declare module "stream-chat" {
  interface CustomMessageData {
    priority?: "high" | "normal";
  }
}
```


---

This page was last updated at 2026-08-07T20:37:55.895Z.

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react/guides/theming/input-ui/](https://getstream.io/chat/docs/sdk/react/guides/theming/input-ui/).