Message UI

The Message UI component is a core building block of the chat experience. Designing it well is tricky, so the SDK includes a pre-built MessageSimple component that’s easy to customize via CSS variables or component overrides (ComponentContext).

Best Practices

  • Start by theming MessageSimple before building a custom Message UI component.
  • Use MessageText to preserve markdown, links, and mentions.
  • Keep custom message layout consistent with thread and list views.
  • Access message data via useMessageContext instead of prop drilling.
  • Test the custom Message UI component with all message types (system, deleted, attachments).

In this guide, we’ll build a simplified custom Message UI component using both pre-built and custom components.

Message Text and Avatars

Start with the simplest Message UI component: render raw text.

import { useMessageContext, Channel } from "stream-chat-react";

const CustomMessageUi = () => {
  const { message } = useMessageContext();

  return <div data-message-id={message.id}>{message.text}</div>;
};

The Message UI component and its children can access MessageContext via useMessageContext for message data and handlers.

To see the changes, pass this component to Channel or MessageList/VirtualizedMessageList via the Message prop.

<Channel Message={CustomMessageUi}>...</Channel>

All messages now appear on one side and we can’t tell who sent them. Let’s fix that with CSS and render the sender name via message.user.

Our message will be on the right and the message of the other senders will be on the left side of the screen.

import { useMessageContext, Channel } from "stream-chat-react";

const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["custom-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("custom-message-ui--mine");
  } else {
    messageUiClassNames.push("custom-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")} data-message-id={message.id}>
      <strong className="custom-message-ui__name">
        {message.user?.name || message.user?.id}
      </strong>
      <span>{message.text}</span>
    </div>
  );
};

This already looks better, but we can do more. Let’s switch to avatars using the pre-built Avatar component.

import { Avatar, useMessageContext, Channel } from "stream-chat-react";

const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["custom-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("custom-message-ui--mine");
  } else {
    messageUiClassNames.push("custom-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")} data-message-id={message.id}>
      <Avatar
        image={message.user?.image}
        name={message.user?.name || message.user?.id}
      />
      <span className="custom-message-ui__text">{message.text}</span>
    </div>
  );
};

Our UI looks good, but what about complex text (links, mentions, markdown)? Right now it’s plain text and not interactive.

Let’s improve this by using MessageText, which uses renderText to turn links, mentions, and Markdown into interactive elements.

import {
  Avatar,
  MessageText,
  useMessageContext,
  Channel,
} from "stream-chat-react";

const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["custom-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("custom-message-ui--mine");
  } else {
    messageUiClassNames.push("custom-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")} data-message-id={message.id}>
      <Avatar
        image={message.user?.image}
        name={message.user?.name || message.user?.id}
      />
      <MessageText />
    </div>
  );
};

Mention highlights don’t include a default click handler. See the Mentions Actions guide for more.

Metadata

So far we’ve covered avatars and text rendering, but the UI still feels sparse. Let’s add creation date, “edited” status, and delivery/read indicators.

import { useMemo } from "react";
import { LocalMessage, UserResponse } from "stream-chat";
import {
  Avatar,
  MessageText,
  useMessageContext,
  useChatContext,
  Channel,
} from "stream-chat-react";

const statusIconMap = {
  delivered: "✅",
  read: "👁️",
  sent: "✉️",
  sending: "🛫",
  unknown: "❓",
};

type MessageStatusParams = {
  message: LocalMessage;
  ownUserId: string;
  deliveredTo: UserResponse[];
  readBy: UserResponse[];
  threadList: boolean;
};

const getMessageStatus = ({
  message,
  ownUserId,
  deliveredTo,
  readBy,
  threadList,
}: MessageStatusParams): keyof typeof statusIconMap => {
  const [firstReader] = readBy;
  const [firstDeliveredUser] = deliveredTo;

  const justReadByMe = readBy?.length === 1 && firstReader?.id === ownUserId;
  const read = !!(readBy?.length && !justReadByMe && !threadList);

  const deliveredOnlyToMe =
    deliveredTo?.length === 1 && firstDeliveredUser?.id === ownUserId;
  const delivered = !!(
    deliveredTo?.length &&
    !deliveredOnlyToMe &&
    !read &&
    !threadList
  );

  const sent =
    message.status === "received" && !delivered && !read && !threadList;
  const sending = message.status === "sending";

  if (read) return "read";
  if (delivered) return "delivered";
  if (sent) return "sent";
  if (sending) return "sending";
  return "unknown";
};

const CustomMessageUiMetadata = () => {
  const {
    deliveredTo = [],
    message,
    readBy = [],
    threadList,
  } = useMessageContext();
  const { client } = useChatContext();
  const {
    created_at: createdAt,
    message_text_updated_at: messageTextUpdatedAt,
  } = message;

  const status = useMemo(
    () =>
      getMessageStatus({
        message,
        ownUserId: client.user?.id ?? "",
        deliveredTo,
        readBy,
        threadList,
      }),
    [message, client, deliveredTo, readBy, threadList],
  );

  return (
    <div className="custom-message-ui__metadata">
      <div className="custom-message-ui__metadata-created-at">
        {createdAt?.toLocaleString()}
      </div>
      <div className="custom-message-ui__metadata-read-status">
        {statusIconMap[status]}
      </div>
      {messageTextUpdatedAt && (
        <div
          className="custom-message-ui__metadata-edited-status"
          title={messageTextUpdatedAt}
        >
          Edited
        </div>
      )}
    </div>
  );
};

const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["custom-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("custom-message-ui--mine");
  } else {
    messageUiClassNames.push("custom-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")} data-message-id={message.id}>
      <div className="custom-message-ui__body">
        <Avatar
          image={message.user?.image}
          name={message.user?.name || message.user?.id}
        />
        <MessageText />
      </div>
      <CustomMessageUiMetadata />
    </div>
  );
};

Message grouping is handled by the SDK. Each message wrapper gets a class, so you can show metadata only when it makes sense and keep the UI clean.

.custom-message-ui__metadata {
  /* removed-line */
  display: flex;
  /* added-line */
  display: none;
  font-size: x-small;
  align-items: baseline;
}

/* added-block-start */
.str-chat__li--bottom .custom-message-ui__metadata,
.str-chat__li--single .custom-message-ui__metadata {
  display: flex;
}
/* added-block-end */

You can utilize MessageContext properties firstOfGroup, endOfGroup, and groupedByUser if you use VirtualizedMessageList for conditional metadata rendering. These properties are not available in regular MessageList.

The SDK also provides MessageStatus and MessageTimestamp. They include extra logic and cover most use cases, so we won’t replace them here.

Message Actions

So far we’ve focused on presentation. Beyond mentions and links, there’s little interactivity. Next, we’ll add message actions (delete, reply, pin, etc.) and reactions.

MessageContext provides message data and handlers. To implement a subset of actions, use handleDelete, handlePin, handleFlag, and handleThread and wire them to your UI buttons.

import { useMemo } from "react";
import { LocalMessage, UserResponse } from "stream-chat";
import {
  Avatar,
  MessageText,
  useMessageContext,
  useChatContext,
  Channel,
} from "stream-chat-react";

const statusIconMap = {
  delivered: "✅",
  read: "👁️",
  sent: "✉️",
  sending: "🛫",
  unknown: "❓",
};

const CustomMessageUiActions = () => {
  const {
    handleDelete,
    handleFlag,
    handleOpenThread,
    handlePin,
    message,
    threadList,
  } = useMessageContext();

  if (threadList) return null;

  return (
    <div className="custom-message-ui__actions">
      <div className="custom-message-ui__actions-group">
        <button onClick={handlePin} title={message.pinned ? "Unpin" : "Pin"}>
          {message.pinned ? "📍" : "📌"}
        </button>
        <button onClick={handleDelete} title="Delete">
          🗑️
        </button>
        <button onClick={handleOpenThread} title="Open thread">
          ↩️
        </button>
        <button onClick={handleFlag} title="Flag message">
          🚩
        </button>
      </div>
    </div>
  );
};

type MessageStatusParams = {
  message: LocalMessage;
  ownUserId: string;
  deliveredTo: UserResponse[];
  readBy: UserResponse[];
  threadList: boolean;
};

const getMessageStatus = ({
  message,
  ownUserId,
  deliveredTo,
  readBy,
  threadList,
}: MessageStatusParams): keyof typeof statusIconMap => {
  const [firstReader] = readBy;
  const [firstDeliveredUser] = deliveredTo;

  const justReadByMe = readBy?.length === 1 && firstReader?.id === ownUserId;
  const read = !!(readBy?.length && !justReadByMe && !threadList);

  const deliveredOnlyToMe =
    deliveredTo?.length === 1 && firstDeliveredUser?.id === ownUserId;
  const delivered = !!(
    deliveredTo?.length &&
    !deliveredOnlyToMe &&
    !read &&
    !threadList
  );

  const sent =
    message.status === "received" && !delivered && !read && !threadList;
  const sending = message.status === "sending";

  if (read) return "read";
  if (delivered) return "delivered";
  if (sent) return "sent";
  if (sending) return "sending";
  return "unknown";
};

const CustomMessageUiMetadata = () => {
  const {
    deliveredTo = [],
    message,
    readBy = [],
    threadList,
  } = useMessageContext();
  const { client } = useChatContext();
  const {
    created_at: createdAt,
    message_text_updated_at: messageTextUpdatedAt,
  } = message;

  const status = useMemo(
    () =>
      getMessageStatus({
        message,
        ownUserId: client.user?.id ?? "",
        deliveredTo,
        readBy,
        threadList,
      }),
    [message, client, deliveredTo, readBy, threadList],
  );

  return (
    <div className="custom-message-ui__metadata">
      <div className="custom-message-ui__metadata-created-at">
        {createdAt?.toLocaleString()}
      </div>
      <div className="custom-message-ui__metadata-read-status">
        {statusIconMap[status]}
      </div>
      {messageTextUpdatedAt && (
        <div
          className="custom-message-ui__metadata-edited-status"
          title={messageTextUpdatedAt}
        >
          Edited
        </div>
      )}
    </div>
  );
};

const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["custom-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("custom-message-ui--mine");
  } else {
    messageUiClassNames.push("custom-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")} data-message-id={message.id}>
      <div className="custom-message-ui__body">
        <Avatar
          image={message.user?.image}
          name={message.user?.name || message.user?.id}
        />
        <MessageText />
      </div>
      <CustomMessageUiMetadata />
      <CustomMessageUiActions />
    </div>
  );
};

Now that actions are enabled, we also need UI that reflects message state. We already handle the "pinned" state via message.pinned. See the Pin Indicator guide for more options. Let’s cover a few remaining pieces.

The following code samples contain only the code related to the appropriate components, if you're following along you can copy and add the following examples to whatever you have created up until now. The whole example is at the bottom of this guide.

Thread and Reply Count

First - upon opening a thread and replying to a message, the message's property reply_count changes; let's add the count indicator button beside the rest of the metadata elements so the end users can access the thread from two places.

const CustomMessageUiMetadata = () => {
  const {
    deliveredTo = [],
    handleOpenThread,
    message,
    readBy = [],
    threadList,
  } = useMessageContext();
  const { client } = useChatContext();
  const {
    created_at: createdAt,
    message_text_updated_at: messageTextUpdatedAt,
    // added-line
    reply_count: replyCount = 0,
  } = message;

  const status = useMemo(
    () =>
      getMessageStatus(
        message,
        client.user?.id,
        deliveredTo,
        readBy,
        threadList,
      ),
    [message, client, deliveredTo, readBy, threadList],
  );

  return (
    <div className="custom-message-ui__metadata">
      <div className="custom-message-ui__metadata-created-at">
        {createdAt?.toLocaleString()}
      </div>
      <div className="custom-message-ui__metadata-read-status">
        {statusIconMap[status]}
      </div>
      {messageTextUpdatedAt && (
        <div
          className="custom-message-ui__metadata-edited-status"
          title={messageTextUpdatedAt}
        >
          Edited
        </div>
      )}
      // added-block-start
      {replyCount > 0 && (
        <button
          className="custom-message-ui__metadata-reply-count"
          onClick={handleOpenThread}
        >
          <span>
            {replyCount} {replyCount > 1 ? "replies" : "reply"}
          </span>
        </button>
      )}
      // added-block-end
    </div>
  );
};

Deleted Message

If you allow soft delete, render a different UI for deleted messages. Check message.deleted_at and fall back to “message deleted” text.

const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["custom-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("custom-message-ui--mine");
  } else {
    messageUiClassNames.push("custom-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")} data-message-id={message.id}>
      // added-block-start
      {message.deleted_at && (
        <div className="custom-message-ui__body">
          This message has been deleted...
        </div>
      )}
      {!message.deleted_at && (
        <>
          <div className="custom-message-ui__body">
            <Avatar
              image={message.user?.image}
              name={message.user?.name || message.user?.id}
            />
            <MessageText />
          </div>
          <CustomMessageUiMetadata />
          <CustomMessageUiActions />
        </>
      )}
      // added-block-end
    </div>
  );
};

Reactions

With message actions in place, the UI is more interactive but still incomplete. Next we’ll add a simple reactions selector (thumbs up/down) by reusing ReactionsList. Start by defining customReactionOptions (see Reactions Customization) and passing them to reactionOptions on Channel.

const customReactionOptions = [
  { name: "Thumbs up", type: "+1", Component: () => <>👍</> },
  { name: "Thumbs down", type: "-1", Component: () => <>👎</> },
];

<Channel Message={CustomMessageUi} reactionOptions={customReactionOptions}>
  ...
</Channel>;

And now that's done we can continue by extending our CustomMessageUiActions component using these newly defined options.

const CustomMessageUiActions = () => {
  const {
    handleDelete,
    handleFlag,
    handleOpenThread,
    handlePin,
    // added-line
    handleReaction,
    message,
    threadList,
  } = useMessageContext();

  // added-line
  const { reactionOptions } = useComponentContext();

  if (threadList) return null;

  return (
    <div className="custom-message-ui__actions">
      <div className="custom-message-ui__actions-group">
        <button onClick={handlePin} title={message.pinned ? "Unpin" : "Pin"}>
          {message.pinned ? "📍" : "📌"}
        </button>
        <button onClick={handleDelete} title="Delete">
          🗑️
        </button>
        <button onClick={handleOpenThread} title="Open thread">
          ↩️
        </button>
        <button onClick={handleFlag} title="Flag message">
          🚩
        </button>
      </div>
      // added-block-start
      <div className="custom-message-ui__actions-group">
        {reactionOptions.map(({ Component, name, type }) => (
          <button
            key={type}
            onClick={(e) => handleReaction(type, e)}
            title={`React with: ${name}`}
          >
            <Component />
          </button>
        ))}
      </div>
      // added-block-end
    </div>
  );
};

Finally, we can add the ReactionsList component to our CustomMessageUi component and adjust the styles accordingly.

Complete Example

import { useMemo } from "react";
import { LocalMessage, UserResponse } from "stream-chat";
import {
  Avatar,
  MessageText,
  ReactionsList,
  useMessageContext,
  useChatContext,
  useComponentContext,
  Channel,
} from "stream-chat-react";

const customReactionOptions = [
  { name: "Thumbs up", type: "+1", Component: () => <>👍</> },
  { name: "Thumbs down", type: "-1", Component: () => <>👎</> },
];

const statusIconMap = {
  delivered: "✅",
  read: "👁️",
  sent: "✉️",
  sending: "🛫",
  unknown: "❓",
};

type MessageStatusParams = {
  message: LocalMessage;
  ownUserId: string;
  deliveredTo: UserResponse[];
  readBy: UserResponse[];
  threadList: boolean;
};

const getMessageStatus = ({
  message,
  ownUserId,
  deliveredTo,
  readBy,
  threadList,
}: MessageStatusParams): keyof typeof statusIconMap => {
  const [firstReader] = readBy;
  const [firstDeliveredUser] = deliveredTo;

  const justReadByMe = readBy?.length === 1 && firstReader?.id === ownUserId;
  const read = !!(readBy?.length && !justReadByMe && !threadList);

  const deliveredOnlyToMe =
    deliveredTo?.length === 1 && firstDeliveredUser?.id === ownUserId;
  const delivered = !!(
    deliveredTo?.length &&
    !deliveredOnlyToMe &&
    !read &&
    !threadList
  );

  const sent =
    message.status === "received" && !delivered && !read && !threadList;
  const sending = message.status === "sending";

  if (read) return "read";
  if (delivered) return "delivered";
  if (sent) return "sent";
  if (sending) return "sending";
  return "unknown";
};

const CustomMessageUiActions = () => {
  const {
    handleDelete,
    handleFlag,
    handleOpenThread,
    handlePin,
    handleReaction,
    message,
    threadList,
  } = useMessageContext();

  const { reactionOptions } = useComponentContext();

  if (threadList) return null;

  return (
    <div className="custom-message-ui__actions">
      <div className="custom-message-ui__actions-group">
        <button onClick={handlePin} title={message.pinned ? "Unpin" : "Pin"}>
          {message.pinned ? "📍" : "📌"}
        </button>
        <button onClick={handleDelete} title="Delete">
          🗑️
        </button>
        <button onClick={handleOpenThread} title="Open thread">
          ↩️
        </button>
        <button onClick={handleFlag} title="Flag message">
          🚩
        </button>
      </div>
      <div className="custom-message-ui__actions-group">
        {reactionOptions?.map(({ Component, name, type }) => (
          <button
            key={type}
            onClick={(e) => handleReaction(type, e)}
            title={`React with: ${name}`}
          >
            <Component />
          </button>
        ))}
      </div>
    </div>
  );
};

const CustomMessageUiMetadata = () => {
  const {
    deliveredTo = [],
    handleOpenThread,
    message,
    readBy = [],
    threadList,
  } = useMessageContext();
  const { client } = useChatContext();
  const {
    created_at: createdAt,
    message_text_updated_at: messageTextUpdatedAt,
    reply_count: replyCount = 0,
  } = message;

  const status = useMemo(
    () =>
      getMessageStatus({
        message,
        ownUserId: client.user?.id ?? "",
        deliveredTo,
        readBy,
        threadList,
      }),
    [message, client, deliveredTo, readBy, threadList],
  );

  return (
    <div className="custom-message-ui__metadata">
      <div className="custom-message-ui__metadata-created-at">
        {createdAt?.toLocaleString()}
      </div>
      <div className="custom-message-ui__metadata-read-status">
        {statusIconMap[status]}
      </div>
      {messageTextUpdatedAt && (
        <div
          className="custom-message-ui__metadata-edited-status"
          title={messageTextUpdatedAt}
        >
          Edited
        </div>
      )}
      {replyCount > 0 && (
        <button
          className="custom-message-ui__metadata-reply-count"
          onClick={handleOpenThread}
        >
          <span>
            {replyCount} {replyCount > 1 ? "replies" : "reply"}
          </span>
        </button>
      )}
    </div>
  );
};

const CustomMessageUi = () => {
  const { isMyMessage, message } = useMessageContext();

  const messageUiClassNames = ["custom-message-ui"];

  if (isMyMessage()) {
    messageUiClassNames.push("custom-message-ui--mine");
  } else {
    messageUiClassNames.push("custom-message-ui--other");
  }

  return (
    <div className={messageUiClassNames.join(" ")} data-message-id={message.id}>
      {message.deleted_at && (
        <div className="custom-message-ui__body">
          This message has been deleted...
        </div>
      )}
      {!message.deleted_at && (
        <>
          <div className="custom-message-ui__body">
            <Avatar
              image={message.user?.image}
              name={message.user?.name || message.user?.id}
            />
            <MessageText />
          </div>
          <CustomMessageUiMetadata />
          <CustomMessageUiActions />
          // added-line
          <ReactionsList />
        </>
      )}
    </div>
  );
};

Attachments

The topic of attachments is pretty substantial by itself, so we won't be covering it in this guide. Please, refer to the source code of our default MessageSimple for details on implementation and see the Custom Attachment guide for more customization options.

Read More

Functionalities relevant to the Message UI component that are also not covered in this guide: