Thread header
This recipe builds a thread header of its own. The default header is explained in Thread UI.
What you'll build
- A thread header with the participants' avatars
- The channel name and reply count in the subtitle
- Who is typing a reply 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.
ThreadHeader has no override key. Thread renders only its children, so a header of your own goes where ThreadHeader would.
Best practices
- Read the thread from
useThreadContext()and subscribe tothread.statefor the reply count and the participants. Declare the selectors at module scope. - Take the channel's name from
useChannelPreviewInfo(), as the default header does. - Keep the back and close buttons,
WorkspaceNavigationBackButtonandWorkspaceNavigationCloseButton. They render only when the layout allows going back or closing the thread's panel. A button of your own closes the thread withuseCloseThread(). - Show typing with
<TypingIndicatorHeader threadList />. WiththreadList, it shows the users typing a reply in this thread, not in the channel. - Hide the typing text when typing events are off for the channel, in its configuration (
channel.configState). - Keep the header compact, so the replies keep the space.
Create the component
import type {
ChannelConfig,
ChannelStateData,
EventPayload,
ThreadState,
} from "stream-chat";
import {
Avatar,
TypingIndicatorHeader,
useChannel,
useChannelPreviewInfo,
useChatContext,
useStateStore,
useThreadContext,
WorkspaceNavigationBackButton,
WorkspaceNavigationCloseButton,
} from "stream-chat-react";
import "./CustomThreadHeader.css";
const threadStateSelector = ({ participants, replyCount }: ThreadState) => ({
participants,
replyCount,
});
const typingSelector = ({ typing }: ChannelStateData) => ({ typing });
const typingEventsSelector = ({ typingEvents }: ChannelConfig) => ({
typingEventsEnabled: typingEvents.enabled,
});
export const CustomThreadHeader = () => {
const { client } = useChatContext();
const channel = useChannel();
const thread = useThreadContext();
const { participants = [], replyCount = 0 } =
useStateStore(thread?.state, threadStateSelector) ?? {};
const { typing } = useStateStore(channel.state, typingSelector);
const { typingEventsEnabled } = useStateStore(
channel.configState,
typingEventsSelector,
);
const { displayTitle } = useChannelPreviewInfo({ channel });
// Others typing a reply in this thread. Typing events carry the thread's id as parent_id.
const someoneIsTyping = (
Object.values(typing) as EventPayload<"typing.start">[]
).some(
({ parent_id, user }) =>
parent_id === thread?.id && user?.id !== client.userId,
);
return (
<div className="cookbook-thread-header">
<WorkspaceNavigationBackButton />
<div className="cookbook-thread-header__participants">
{participants.map(({ user }) =>
user ? (
<Avatar
imageUrl={user.image}
key={user.id}
size="sm"
userName={user.name ?? user.id}
/>
) : null,
)}
</div>
<div className="cookbook-thread-header__content">
<div className="cookbook-thread-header__title">Thread</div>
<div className="cookbook-thread-header__subtitle">
{typingEventsEnabled && someoneIsTyping ? (
<TypingIndicatorHeader threadList />
) : (
`${displayTitle} · ${replyCount} ${replyCount === 1 ? "reply" : "replies"}`
)}
</div>
</div>
<WorkspaceNavigationCloseButton />
</div>
);
};@layer stream-overrides {
.cookbook-thread-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-thread-header__participants {
display: flex;
gap: var(--str-chat__spacing-xxs);
}
.cookbook-thread-header__content {
flex: 1;
min-width: 0;
}
.cookbook-thread-header__title {
color: var(--str-chat__text-primary);
font: var(--str-chat__font-heading-xs);
}
.cookbook-thread-header__subtitle {
overflow: hidden;
color: var(--str-chat__text-secondary);
font: var(--str-chat__font-caption-default);
text-overflow: ellipsis;
white-space: nowrap;
}
}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:
useThreadContext()returns the thread thatThreadorThreadSlotrenders.thread.stateholds the participants, each with auser, and the reply count. What channels and threads hold explains the rest.useChannel()returns the thread's channel, souseChannelPreviewInfo()gives its name.channel.state.typingholds the latest typing event of each user in the channel. An event sent from a thread carries the thread's id asparent_id, so the header filters on it to decide whether to show the typing text. The filter is the one from Typing in threads.channel.configStateholds the channel's resolved configuration.typingEvents.enabledisfalsewhen the client configuration or the channel type turns typing events off.TypingIndicatorHeaderwiththreadListrenders "Bob is typing", "Bob and Cara are typing" or "3 people are typing" from the same events, translated like the rest of the SDK.
The reply count text here is plain English. To translate it, use t from useTranslationContext(), as Localization explains.
Render it in the thread
Put the header where ThreadHeader would go. With ChatView, that's inside ThreadSlot:
import {
MessageComposer,
MessageList,
useThreadContext,
} from "stream-chat-react";
import { ThreadSlot, useActiveThread } from "stream-chat-react/slot-layout";
import { CustomThreadHeader } from "./CustomThreadHeader";
// Marks the thread as on screen, so its new replies are marked read.
const ActiveThread = () => {
const thread = useThreadContext();
useActiveThread({ activeThread: thread });
return null;
};
export const ThreadPanel = () => (
<ThreadSlot slot="thread">
<ActiveThread />
<CustomThreadHeader />
<MessageList />
<MessageComposer />
</ThreadSlot>
);ActiveThread tells the thread it's on screen, so its new replies are marked read, as in Channel and thread side by side. Render ThreadPanel in place of the base app's ThreadSlot. Without a layout, render the header inside <Thread thread={thread}> the same way.
How it works
Example
The user opens the thread under Bob's message in Design review, and Cara starts typing a reply:
ThreadSlotrenders the thread, andCustomThreadHeaderreads it withuseThreadContext(). The header shows the participants' avatars fromthread.state.participants, and the subtitle from the channel's name andthread.state.replyCount, such as "Design review · 3 replies".- Cara starts typing in the thread. Her typing event carries the thread's id as
parent_id, and lands inchannel.state.typing. someoneIsTypingistrue, so the subtitle shows<TypingIndicatorHeader threadList />: "Cara is typing".- Cara sends her reply. Her typing entry goes,
thread.state.replyCountgoes up, and the subtitle reads "Design review · 4 replies".
Typing in the channel itself carries no parent_id, so it doesn't change this header.
Related
- Thread UI: the default thread header.
- What channels and threads hold: the participants and reply count in
thread.state. - Typing in threads: the filter on
parent_id. - Replies and threads: how threads open and load.
- Localization: translating the reply count.
- Back and close: what the header's back and close buttons do.
- Channel and thread side by side: the thread panel beside the channel, with
ActiveThread. - Channel header: the same approach for the channel's header.