Channel header
This recipe builds a channel header of its own. The default header and its props are explained in Channel UI. To change only the title or the subtitle, you don't need a header of your own: pass a component as the header's Title or Subtitle, as that section shows.
What you'll build
- A channel header with the channel's avatar and title
- Member and online counts in the subtitle
- Who is typing in the subtitle
Preview

Approach: Compose. Choosing where to start explains the approaches.
Starts from: the base app. The page shows only what the recipe adds.
ChannelHeader has no override key. Channel renders only its children, so a header of your own goes where ChannelHeader would.
Best practices
- Read the title, image and group avatar from
useChannelPreviewInfo(). It follows changes to the channel's name, image and members. - Read counts and typing from
channel.statewithuseStateStore, and declare the selectors at module scope. - Show typing with
TypingIndicatorHeader, the header's "Bob is typing" text, not the message list'sTypingIndicator. - Hide the typing text when typing events are off for the channel, in its configuration (
channel.configState). - Keep the back and close buttons,
WorkspaceNavigationBackButtonandWorkspaceNavigationCloseButton. They render only when the layout allows going back or closing the panel. - Keep the header light: it re-renders on every typing event.
Implementation
The default header switches its subtitle between typing and the online status. This one does the same:
import type {
ChannelConfig,
ChannelStateData,
EventPayload,
} from "stream-chat";
import {
ChannelAvatar,
TypingIndicatorHeader,
useChannel,
useChannelPreviewInfo,
useChatContext,
useStateStore,
WorkspaceNavigationBackButton,
WorkspaceNavigationCloseButton,
} from "stream-chat-react";
import "./CustomChannelHeader.css";
const channelStateSelector = ({
memberCount,
typing,
watcherCount,
}: ChannelStateData) => ({
memberCount,
typing,
watcherCount,
});
const typingEventsSelector = ({ typingEvents }: ChannelConfig) => ({
typingEventsEnabled: typingEvents.enabled,
});
export const CustomChannelHeader = () => {
const { client } = useChatContext();
const channel = useChannel();
const { memberCount, typing, watcherCount } = useStateStore(
channel.state,
channelStateSelector,
);
const { typingEventsEnabled } = useStateStore(
channel.configState,
typingEventsSelector,
);
const { displayImage, displayTitle, groupChannelDisplayInfo } =
useChannelPreviewInfo({
channel,
});
// Others typing in the channel itself; replies typed in threads carry a parent_id.
const someoneIsTyping = (
Object.values(typing) as EventPayload<"typing.start">[]
).some(({ parent_id, user }) => !parent_id && user?.id !== client.userId);
return (
<div className="cookbook-channel-header">
<WorkspaceNavigationBackButton />
<ChannelAvatar
displayMembers={groupChannelDisplayInfo.members}
imageUrl={displayImage}
size="lg"
userName={displayTitle}
/>
<div className="cookbook-channel-header__content">
<div className="cookbook-channel-header__title">{displayTitle}</div>
<div className="cookbook-channel-header__subtitle">
{typingEventsEnabled && someoneIsTyping ? (
<TypingIndicatorHeader />
) : (
`${memberCount} members · ${watcherCount} online`
)}
</div>
</div>
<WorkspaceNavigationCloseButton />
</div>
);
};@layer stream-overrides {
.cookbook-channel-header {
display: flex;
align-items: center;
gap: var(--str-chat__spacing-sm);
padding: var(--str-chat__spacing-sm) var(--str-chat__spacing-md);
border-bottom: 1px solid var(--str-chat__border-core-default);
background: var(--str-chat__background-core-elevation-1);
}
.cookbook-channel-header__content {
flex: 1;
min-width: 0;
}
.cookbook-channel-header__title {
color: var(--str-chat__text-primary);
font: var(--str-chat__font-heading-xs);
}
.cookbook-channel-header__subtitle {
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.
Here is what each part reads:
useChannelPreviewInfo({ channel })gives the title and image the channel list shows: the channel's name, or its members' names when it has none.groupChannelDisplayInfo.membersare the membersChannelAvatardraws when the channel has no image. It works out the overflow badge itself.channel.stateholdsmemberCount,watcherCount, the number of users watching the channel right now, andtyping, the latest typing event of each user. What channels and threads hold explains the rest.channel.configStateholds the channel's resolved configuration.typingEvents.enabledisfalsewhen the client configuration or the channel type turns typing events off.TypingIndicatorHeaderrenders "Bob is typing", "Bob and Cara are typing" or "3 people are typing" from the same events. Typing indicators covers typing end to end.
While a member types

To open channel details from the avatar, render AvatarWithChannelDetail from stream-chat-react/channel-detail in place of ChannelAvatar. See Channel details UI.
Final code
Render the header where ChannelHeader would go. With ChatView, that's inside ChannelSlot:
import {
ChannelNavigation,
Chat,
MessageComposer,
MessageList,
ThreadHeader,
} from "stream-chat-react";
import {
ChannelSlot,
ChatView,
ThreadSlot,
} from "stream-chat-react/slot-layout";
import type { LayoutDescriptor } from "stream-chat-react/slot-layout";
import { CustomChannelHeader } from "./CustomChannelHeader";
const layouts: LayoutDescriptor[] = [
{ id: "channels", slots: ["channel", "thread"] },
];
const ChannelsView = () => (
<>
<ChannelNavigation />
<ChannelSlot slot="channel">
<CustomChannelHeader />
<MessageList />
<MessageComposer />
</ChannelSlot>
<ThreadSlot slot="thread">
<ThreadHeader />
<MessageList />
<MessageComposer />
</ThreadSlot>
</>
);
export const App = () => (
<Chat client={client}>
<ChatView layouts={layouts} views={{ channels: <ChannelsView /> }} />
</Chat>
);Without a layout, render the header inside <Channel channel={channel}> the same way.
How it works
Example
The user has Design review open, and Bob starts typing:
CustomChannelHeadershows the channel's title and image fromuseChannelPreviewInfo(), and the subtitle fromchannel.state.memberCountandchannel.state.watcherCount, such as "4 members · 2 online".- Bob starts typing. His typing event arrives,
channel.state.typinggets an entry for him, anduseStateStorerenders the header again. - The event has no
parent_idand isn't the user's own, sosomeoneIsTypingistrue. The subtitle showsTypingIndicatorHeader: "Bob is typing". - Bob stops typing. His entry leaves
channel.state.typing, and the subtitle shows the counts again.
A reply Bob types in a thread carries the thread's id as parent_id, so it doesn't change this header. When channel.configState says typing events are off, the subtitle always shows the counts.
Related
- Channel UI: the default header and its props.
- What channels and threads hold: the counts and typing events in
channel.state. - Typing indicators: typing end to end.
- Reading state in components:
useStateStoreand its selectors. - Back and close: what the header's back and close buttons do.
- Thread header: the same approach for the thread's header.