import clsx from "clsx";
import type { Notification, NotificationSeverity } from "stream-chat";
import { type ComponentType, useEffect, useState } from "react";
import {
IconCheckmark,
IconExclamationCircleFill,
IconExclamationTriangleFill,
IconLoading,
createIcon,
useSystemNotifications,
} from "stream-chat-react";
export const IconInfoCircle = createIcon(
"IconInfoCircle",
<path
d="M4.42891 16.875L12.9953 8.30781C13.0534 8.2497 13.1223 8.2036 13.1982 8.17215C13.274 8.1407 13.3554 8.12451 13.4375 8.12451C13.5196 8.12451 13.601 8.1407 13.6768 8.17215C13.7527 8.2036 13.8216 8.2497 13.8797 8.30781L16.875 11.3039M3.75 3.125H16.25C16.5952 3.125 16.875 3.40482 16.875 3.75V16.25C16.875 16.5952 16.5952 16.875 16.25 16.875H3.75C3.40482 16.875 3.125 16.5952 3.125 16.25V3.75C3.125 3.40482 3.40482 3.125 3.75 3.125ZM8.75 7.5C8.75 8.19036 8.19036 8.75 7.5 8.75C6.80964 8.75 6.25 8.19036 6.25 7.5C6.25 6.80964 6.80964 6.25 7.5 6.25C8.19036 6.25 8.75 6.80964 8.75 7.5Z"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>,
);
const IconsBySeverity: Record<NotificationSeverity, ComponentType | null> = {
error: IconExclamationCircleFill,
info: IconInfoCircle,
loading: IconLoading,
success: IconCheckmark,
warning: IconExclamationTriangleFill,
};
export function SystemNotification() {
const notifications = useSystemNotifications();
const notification = notifications[0];
const [retained, setRetained] = useState<Notification | undefined>(
notification,
);
useEffect(() => {
if (notification) setRetained(notification);
}, [notification]);
const isExiting = !notification && !!retained;
const rendered = notification ?? retained;
if (!rendered) return null;
const Icon = rendered.severity
? (IconsBySeverity[rendered.severity] ?? null)
: IconExclamationCircleFill;
const action = rendered.actions?.[0];
return (
<div
aria-live="polite"
className={clsx("str-chat__system-notification", {
"str-chat__system-notification--exiting": isExiting,
"str-chat__system-notification--interactive": action,
[`str-chat__system-notification--${rendered.severity}`]:
rendered.severity,
})}
onAnimationEnd={(e) => {
if (e.animationName === "str-chat__system-notification-slide-out") {
setRetained(undefined);
}
}}
onClick={action?.handler}
onKeyDown={
action
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
action.handler();
}
}
: undefined
}
role={action ? "button" : "status"}
tabIndex={action ? 0 : undefined}
>
{Icon && (
<span aria-hidden className="str-chat__system-notification-icon">
<Icon />
</span>
)}
<span className="str-chat__system-notification-message">
{rendered.message}
</span>
</div>
);
}System notification banner
Panel NotificationList components hide notifications that carry the system tag, so those items never appear as inline toasts. The <Chat> component still publishes a persistent notification when the WebSocket connection drops (type: system:network:connection:lost). To show that (or any other system-tagged notification) as a full-width bar, render a small subscriber component inside <Chat> next to your layout.
Use useSystemNotifications from stream-chat-react to subscribe to the same subset NotificationList hides from toasts (system-banner notifications).
When to use this pattern
- You want a global connection banner without duplicating
connection.changedlisteners. - You publish with
addSystemNotificationand want them in the same banner.
Implementation
1. Banner component
Call useSystemNotifications() to get every active system-banner notification as an array (same ordering as the client store). The sample below is a single-slot UI: it reads notifications[0] only, so if more than one system notification exists at once, the rest are ignored until that one is removed. To show several at a time, map over notifications or implement your own queue / stacking.
Pass filter to useSystemNotifications({ filter }) to narrow further (e.g. only system:network:connection:lost).
2. Styles
The banner uses BEM classes under str-chat__system-notification*. Ship matching rules in your own stylesheet (in-flow bar, slide-in/out on mount and unmount, spinner on the icon when severity is loading). Import that sheet inside an appropriate CSS cascade layer when you need to override base styles—for example @import url('./SystemNotification.scss') layer(stream-app-overrides);.
3. Mount inside Chat
useSystemNotifications requires the chat client context from <Chat> (same as useNotifications).
<Chat client={client}>
<div className="app-chat-layout">
<SystemNotification />
<div className="app-chat-layout__body">{/* main chat UI */}</div>
</div>
</Chat>Pair this with a column flex shell (for example #root { height: 100vh; display: flex; flex-direction: column; min-height: 0 }) and layout rules so .app-chat-layout and .app-chat-layout__body use flex: 1, min-height: 0, and the ChatView root grows within the body beneath the banner.
Publishing your own system notifications
Prefer useNotificationApi().addSystemNotification: it applies the system tag, omits target:* panel tags (so the item stays global), and accepts the same fields as addNotification except targetPanels.
import { useNotificationApi } from "stream-chat-react";
function MaintenanceBannerTrigger() {
const { addSystemNotification } = useNotificationApi();
const notify = () => {
addSystemNotification({
emitter: "MaintenanceScheduler",
message: "Maintenance in 10 minutes",
duration: 0,
severity: "warning",
type: "app:maintenance:upcoming",
});
};
return (
<button type="button" onClick={notify}>
Schedule notice
</button>
);
}Optional tags are merged with the built-in system tag (deduplicated).
See the notifications guide for the full notification model, NotificationList, and translation hooks.