Custom composer
This recipe builds a composer with a layout of its own and keeps how the composer works. Which parts the default composer has and what each one reads is explained in Message composer UI.
What you'll build
- A composer layout of its own, with the default attachment menu and previews
- An "Also send in channel" checkbox in threads
Preview

Approach: Replace one part and Compose. Choosing where to start explains the approaches.
Starts from: the base app. The page shows only what the recipe adds.
Best practices
- Replace
<MessageComposerUI/>and keep rendering<MessageComposer/>.<MessageComposer/>picks the channel's or the thread's composer, saves the draft when it unmounts if drafts are on (messageComposer.config.drafts.enabled, off by default), and provides the context the parts need: the textarea, paste handling and voice recording. - Build the layout from the exported parts. Each one reads the composer from
useMessageComposerController(), so the same layout works in a channel and in a thread. - Keep
<TextareaComposer/>. It sends typing events, opens the suggestion list for@and/, shows commands and sends on Enter. - Render
<MessageComposerActions/>for the send button. It shows the stop button while an AI is thinking or generating, the cooldown timer during slow mode, and the record button when voice recording is on and the input is empty. A send button of your own calls the function fromuseMessageComposerSubmitFn(), which saves an edit or sends a new message. - Hide the attachment button while a command is active, as the default composer does. A command takes the whole composer.
- Test the layout with a quoted message, attachments, a link, a voice recording, a location, a command, in a thread and while editing.
Build the layout
The layout renders the default previews with <MessageComposerPreviews/>: the message being edited or quoted, a voice recording, attachments, link previews and a location, each only while there is one. So it also handles editing: while a message is being edited, the edited message shows above the input, and cancelling the edit puts back what the user was writing.
The "also send in channel" checkbox isn't exported, so the layout has its own for thread replies, <AlsoSendInChannel/>, the last part of this page.
import type { TextComposerState } from "stream-chat";
import {
AttachmentSelector,
AudioRecorder,
CommandChip,
MessageComposerActions,
MessageComposerPreviews,
TextareaComposer,
useMessageComposerContext,
useMessageComposerController,
useStateStore,
WithDragAndDropUpload,
} from "stream-chat-react";
import { AlsoSendInChannel } from "./AlsoSendInChannel";
import "./CustomMessageComposer.css";
const commandSelector = ({ command }: TextComposerState) => ({ command });
export const CustomMessageComposer = () => {
const { recordingController } = useMessageComposerContext();
const messageComposer = useMessageComposerController();
const { command } = useStateStore(
messageComposer.textComposer.state,
commandSelector,
);
// While the user records a voice message, the recorder takes the composer's place.
if (recordingController.recordingState) return <AudioRecorder />;
return (
// Channel and Thread stack a drop zone placed directly inside them in a column.
// This wrapper keeps the composer's row out of that rule; it needs no styles of its own.
<div className="cookbook-composer-root">
<WithDragAndDropUpload className="cookbook-composer" component="div">
{!command && <AttachmentSelector />}
<div className="cookbook-composer__center">
{/* The message being edited or quoted, the voice recording, attachments, link previews
and the location, each only while there is one. */}
<MessageComposerPreviews />
<div className="cookbook-composer__input">
{command && <CommandChip command={command} />}
<TextareaComposer />
</div>
<AlsoSendInChannel />
</div>
<MessageComposerActions />
</WithDragAndDropUpload>
</div>
);
};@layer stream-overrides {
.cookbook-composer {
display: flex;
align-items: flex-end;
gap: var(--str-chat__spacing-sm);
padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-md);
}
.cookbook-composer__center {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--str-chat__spacing-xs);
min-width: 0;
padding: var(--str-chat__spacing-xs) var(--str-chat__spacing-sm);
border: 1px solid var(--str-chat__border-core-default);
border-radius: var(--str-chat__radius-xl);
background: var(--str-chat__background-core-elevation-1);
}
.cookbook-composer__input {
display: flex;
align-items: center;
gap: var(--str-chat__spacing-xs);
}
.cookbook-composer__also-send {
display: flex;
align-items: center;
gap: var(--str-chat__spacing-xs);
color: var(--str-chat__text-secondary);
font: var(--str-chat__font-caption-default);
}
}The rules sit in the stream-overrides layer, so they win over the SDK's own rules once the SDK's stylesheet is in an earlier layer, as Theming → CSS layers sets up. They take their colors, spacing and type from the SDK's tokens, so they follow your theme. The middle column gets the border and background the default composer draws around its input.
Here is what each part does:
<WithDragAndDropUpload/>uploads files dropped on the composer, as the default composer does.<AttachmentSelector/>opens the menu to upload a file, create a poll, share a location or pick a command. The menu is an action set, explained in Attachment Selector.<MessageComposerPreviews/>renders each preview of its preview set only while the composer has something for it. Custom previews explains how to add, remove or reorder them.<CommandChip/>shows the picked command, such as/giphy, with a button that clears it.<MessageComposerActions/>shows one control, the first that applies of: the stop button, the cooldown timer, the record button and the send button.
The emoji picker isn't in this layout. To add it, render <AdditionalMessageComposerActions/> before <MessageComposerActions/>, as the default composer does. It renders the EmojiPicker override, explained in Emoji.
The parts are imported directly, so an override registered for one of them, such as a MessageComposerPreviews of your own, doesn't apply inside this layout. The previews inside <MessageComposerPreviews/> still use theirs, such as an AttachmentPreviewList of your own. To keep the overrides of the other parts, read the parts from useComponentContext() and fall back to the imports.
Register it
Register the layout under the MessageComposerUI key, around the composers it applies to. Declare the overrides once, so <WithComponents/> gets the same object on every render:
import type { PropsWithChildren } from "react";
import { WithComponents } from "stream-chat-react";
import { CustomMessageComposer } from "./CustomMessageComposer";
// Declared once, so WithComponents gets the same object on every render.
const overrides = { MessageComposerUI: CustomMessageComposer };
export const WithCustomComposer = ({ children }: PropsWithChildren) => (
<WithComponents overrides={overrides}>{children}</WithComponents>
);Wrap <ChatView/> in <WithCustomComposer/>, and the layout applies to the composer of every channel and thread. To limit it, wrap a single <Channel/> or <Thread/> instead. Overriding components explains how overrides apply.
Thread replies
In a thread, the default composer shows a checkbox that also posts the reply to the channel. It isn't exported, so a layout of your own adds one. The composer keeps the choice in messageComposer.state, as showReplyInChannel, and messageComposer.threadId is set only on a thread's composer:
import type { MessageComposerState } from "stream-chat";
import { useMessageComposerController, useStateStore } from "stream-chat-react";
const showReplyInChannelSelector = ({
showReplyInChannel,
}: MessageComposerState) => ({
showReplyInChannel,
});
export const AlsoSendInChannel = () => {
const messageComposer = useMessageComposerController();
const { showReplyInChannel } = useStateStore(
messageComposer.state,
showReplyInChannelSelector,
);
if (!messageComposer.threadId) return null;
return (
<label className="cookbook-composer__also-send">
<input
checked={showReplyInChannel}
onChange={() => messageComposer.toggleShowReplyInChannel()}
type="checkbox"
/>
Also send in channel
</label>
);
};<CustomMessageComposer/> renders <AlsoSendInChannel/> under the input. It renders nothing in a channel. The default checkbox reads "Also send as a direct message" in a channel with two members; change the label the same way if your app has direct messages.
How it works
Example
The user quotes Bob's message in Design review and sends a reply:
<MessageComposer/>picks the channel's composer and renders theMessageComposerUIoverride,<CustomMessageComposer/>. Nothing is being recorded, so it renders the layout.- The user quotes Bob's message.
<MessageComposerPreviews/>shows its quoted message preview above the input, in the middle column. - The user types the reply in
<TextareaComposer/>, which sends typing events as they type. - The user presses Enter, or the send button in
<MessageComposerActions/>. The composer sends the message with the quote, and the input and the preview empty.
When the user edits a message, the same layout shows the edited message above the input. In a thread, the same layout renders, and <AlsoSendInChannel/> shows its checkbox because messageComposer.threadId is set.
Related
- Message composer UI: the default composer's parts and what each one reads.
- Message composer: the composer object the parts read.
- Overriding components: how overrides registered with
<WithComponents/>apply. - Actions: the attachment menu's action set.
- Emoji: the emoji picker this layout leaves out.
- Attachment previews: the previews above the input, replaced one kind at a time.
- Custom suggestions: the suggestion list
<TextareaComposer/>opens.