Pin indicator
A pinned message shows a "Pinned by" line above its bubble. This recipe changes how that line looks and what it shows.
What you'll build
- A "Pinned by" line restyled as a chip
- Who pinned the message and on which day
- When the pin expires
- An Unpin button
Preview

Approach: Restyle and Replace one part. Choosing where to start explains the approaches.
Starts from: the base app. The page shows only what the recipe adds.
Best practices
- Restyle the default indicator with CSS when only its look changes. A pinned message gets the class
str-chat__message--pinned, and the indicator rendersstr-chat__message-pin-indicator. - Replace
PinIndicatorwhen the indicator needs more data or an action. It renders inside the message, so it can readuseMessageContext(). - Keep the
str-chat__message-pin-indicatorclass on your component's root. The message's grid uses it to place the indicator above the bubble. - Offer Unpin only to users with the
pin-messagecapability, as the default Pin action does. The server rejects the request from a user without it. - Keep the indicator to one short line, so it doesn't push the message content around.
Custom pin indicator
CSS based solution
By default, a pinned message shows the "Pinned by" line above its bubble, and its row in the list is highlighted:
The default pin indicator

The default indicator is an icon and a label inside str-chat__message-pin-indicator__content. These rules turn it into a chip with a border and an icon in the accent color:
@layer stream-overrides {
.str-chat__message--pinned
.str-chat__message-pin-indicator
.str-chat__message-pin-indicator__content {
width: fit-content;
padding: var(--str-chat__spacing-xxs) var(--str-chat__spacing-xs);
border: 1px solid var(--str-chat__accent-primary);
border-radius: var(--str-chat__radius-sm);
background-color: var(--str-chat__background-core-surface-subtle);
}
.str-chat__message--pinned
.str-chat__message-pin-indicator__content
.str-chat__message-pin-indicator__icon {
color: var(--str-chat__accent-primary);
}
}The indicator as a chip

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 icon takes the text color, so color on the icon colors it. The highlighted row comes from the list item around a pinned message, which paints --str-chat__background-core-highlight behind it.
Component based solution
CSS can't add information. This indicator says who pinned the message and on which day, shows when the pin expires, and has an Unpin button:
import type { PropsWithChildren } from "react";
import { convertTimestampToDate } from "stream-chat";
import {
useChatContext,
useMessageContext,
useMessagePermissions,
WithComponents,
} from "stream-chat-react";
import "./PinnedByIndicator.css";
const PinnedByIndicator = () => {
const { client } = useChatContext();
const { handlePin, message } = useMessageContext();
const { canPin } = useMessagePermissions(message);
if (!message.pinned) return null;
const pinnedBy = message.pinned_by;
const pinnedByName =
pinnedBy?.id === client.user?.id
? "you"
: (pinnedBy?.name ?? pinnedBy?.id ?? "someone");
const pinnedAt = convertTimestampToDate(message.pinned_at);
const expiresAt = convertTimestampToDate(message.pin_expires);
return (
<div className="str-chat__message-pin-indicator cookbook-pinned-by">
<span>
<span aria-hidden>📌</span> Pinned by {pinnedByName}
{pinnedAt && ` on ${pinnedAt.toLocaleDateString()}`}
</span>
{expiresAt && (
<span className="cookbook-pinned-by__expiry">
until {expiresAt.toLocaleString()}
</span>
)}
{canPin && (
<button
className="cookbook-pinned-by__unpin"
onClick={handlePin}
type="button"
>
Unpin
</button>
)}
</div>
);
};
// Declared once, so WithComponents gets the same object on every render.
const overrides = { PinIndicator: PinnedByIndicator };
export const WithPinnedByIndicator = ({ children }: PropsWithChildren) => (
<WithComponents overrides={overrides}>{children}</WithComponents>
);@layer stream-overrides {
.cookbook-pinned-by {
display: flex;
align-items: center;
gap: var(--str-chat__spacing-xs);
color: var(--str-chat__text-secondary);
font: var(--str-chat__font-caption-default);
}
.cookbook-pinned-by__expiry {
color: var(--str-chat__text-tertiary);
}
.cookbook-pinned-by__unpin {
padding: 0;
border: 0;
background: none;
color: var(--str-chat__text-link);
font: var(--str-chat__font-caption-emphasis);
cursor: pointer;
}
}The screenshot at the top of this page shows the result.
Wrap the channel in WithPinnedByIndicator:
import type { Channel as StreamChannel } from "stream-chat";
import { Channel, MessageComposer, MessageList } from "stream-chat-react";
import { WithPinnedByIndicator } from "./PinnedByIndicator";
export const PinnedChannel = ({ channel }: { channel: StreamChannel }) => (
<WithPinnedByIndicator>
<Channel channel={channel}>
<MessageList />
<MessageComposer />
</Channel>
</WithPinnedByIndicator>
);How it works
- The default
MessageUIrenders thePinIndicatorkey above the bubble of every message withmessage.pinned, and passes the message asmessage.PinnedByIndicatorreads it fromMessageContextinstead, together withhandlePin. pinned_by,pinned_atandpin_expiresare on the message. The timestamps are nanosecond numbers, soconvertTimestampToDateturns them into dates; Data conventions explains why.useMessagePermissions(message).canPinis the user'spin-messagecapability in the channel. Without it, the button isn't rendered.- Clicking Unpin calls
handlePin, which unpins a pinned message. The indicator disappears at once, and comes back if the request fails.
The indicator only renders where the default MessageUI does. A custom message UI renders whatever it wants for a pinned message.
Related
- Pinned messages explains how pinning works and the list of a channel's pinned messages. Turning it on or off there explains who may pin.
- Actions replaces the Pin action with one that asks for confirmation.
- Permissions reference lists the
pin-messagecapability and the roles that have it. - Theming puts the SDK's stylesheet in a layer, so the
stream-overridesrules win. - MessageContext lists
handlePinand the message's other handlers. - Custom message replaces the message UI that renders the indicator.