System notification banner

A system notification is about the whole app, not one panel: "Waiting for network…" or "Reconnecting…". Chat publishes one while the client can't reach Stream, but renders nothing for it, and no NotificationList shows it. System notifications and the hook that reads them are explained in Notifications UI.

Note:

What you'll build

  • A bar across the top of the app for system notifications
  • A connection banner while the client can't reach Stream
  • Notifications with an action the user can click
  • Your own app-wide notices, such as upcoming maintenance

Preview

A system notification bar with a warning icon and "Maintenance in 10 minutes" across the top of the app

Approach: Compose. Choosing where to start explains the approaches.

Starts from: the base app. The page shows only what the recipe adds.

When to use this pattern

  • You want a connection banner without writing your own connection handling. Chat already reads both connection facts, the device's network and the socket to Stream, and holds a short socket drop back so the banner doesn't flicker. Connection status explains them.
  • You publish app-wide messages, such as an upcoming maintenance, and want them in the same bar.

Connection status banner

For a connection banner only, keep the lost-connection notification and render its message. Its type is system:network:connection:lost, for both messages: "Waiting for network…" when the device reports no network, and "Reconnecting…" when the network is up and only the socket is down. The message is already translated and says which one it is.

Declare the filter at module scope. useSystemNotifications builds its subscription from it, so an inline filter resubscribes on every render, as Selector identity explains:

src/ConnectionStatusBanner.tsx
import type { Notification } from "stream-chat";
import { useSystemNotifications } from "stream-chat-react";
import "./ConnectionStatusBanner.css";

const isConnectionLost = (notification: Notification) =>
  notification.type === "system:network:connection:lost";

export const ConnectionStatusBanner = () => {
  const [notification] = useSystemNotifications({ filter: isConnectionLost });

  // <NotificationAnnouncer/> in <Chat/> already reads the message to screen readers.
  if (!notification) return null;

  return (
    <div className="cookbook-connection-status">{notification.message}</div>
  );
};

While the device is offline

A bar reading "Waiting for network…" across the top of the app while the device is offline

Implementation

The full banner shows every system notification, one at a time.

1. Banner component and styles

useSystemNotifications() returns the system notifications in the order client.notifications keeps them, oldest first by default. The banner shows the first. When it's removed, the banner keeps it on screen until the slide-out animation ends; with reduced motion on, there's no animation, so it goes at once. A notification with an action makes the whole bar a button, until it starts to slide out. The banner has no role="status": <NotificationAnnouncer/>, which <Chat/> renders, already reads system notifications to screen readers, so a status region would announce each message twice:

The SDK has no styles for this bar, so the stylesheet sits beside the component. Its rules make the banner an in-flow bar that slides in and out, color the icon by severity, and spin it while the severity is loading. With reduced motion on, they turn the animations off:

src/SystemNotificationBanner.tsx
import type { ComponentType } from "react";
import { useEffect, useState } from "react";
import type { Notification } from "stream-chat";
import {
  IconCheckmark,
  IconExclamationCircleFill,
  IconExclamationTriangleFill,
  IconInfo,
  IconLoading,
  useSystemNotifications,
} from "stream-chat-react";
import "./SystemNotificationBanner.css";

const iconsBySeverity: Record<string, ComponentType> = {
  error: IconExclamationCircleFill,
  info: IconInfo,
  loading: IconLoading,
  success: IconCheckmark,
  warning: IconExclamationTriangleFill,
};

export const SystemNotificationBanner = () => {
  const [notification] = useSystemNotifications();
  // Without animations there is no slide-out to wait for, so the bar goes at once.
  const reduceMotion = window.matchMedia(
    "(prefers-reduced-motion: reduce)",
  ).matches;
  // The last notification shown, kept while the bar slides out.
  const [retained, setRetained] = useState<Notification | undefined>(
    notification,
  );

  useEffect(() => {
    if (notification) setRetained(notification);
    else if (reduceMotion) setRetained(undefined);
  }, [notification, reduceMotion]);

  const shown = notification ?? (reduceMotion ? undefined : retained);
  const isExiting = !notification;
  // A bar on its way out no longer acts on a click.
  const action = isExiting ? undefined : shown?.actions?.[0];
  const Icon = shown && iconsBySeverity[shown.severity ?? "error"];

  const className = [
    "cookbook-system-banner",
    shown?.severity && `cookbook-system-banner--${shown.severity}`,
    isExiting && "cookbook-system-banner--exiting",
    action && "cookbook-system-banner--interactive",
  ]
    .filter(Boolean)
    .join(" ");

  // <NotificationAnnouncer/> in <Chat/> already reads each message to screen readers.
  if (!shown) return null;

  return (
    <div
      className={className}
      onAnimationEnd={(event) => {
        if (event.animationName === "cookbook-system-banner-slide-out") {
          setRetained(undefined);
        }
      }}
      onClick={action?.handler}
      onKeyDown={(event) => {
        if (action && (event.key === "Enter" || event.key === " ")) {
          event.preventDefault();
          action.handler();
        }
      }}
      role={action ? "button" : undefined}
      tabIndex={action ? 0 : undefined}
    >
      {Icon && (
        <span aria-hidden className="cookbook-system-banner__icon">
          <Icon />
        </span>
      )}
      <span className="cookbook-system-banner__message">{shown.message}</span>
    </div>
  );
};

To show only some system notifications, pass a module-scope filter, as in the connection banner. To show several at once, map over the array. 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. Their colors, spacing and type come from the SDK's tokens, which resolve only inside an element with the str-chat class. The banner sits outside ChatView, so the layout below gives its own root that class.

2. Mount inside Chat

useSystemNotifications reads client.notifications through Chat, so the banner must render inside it. AppLayout puts it above the rest of the app, in a column. It reads the theme from useChatContext(), so the banner follows the theme passed to Chat:

src/AppLayout.tsx
import type { PropsWithChildren, ReactNode } from "react";
import { useChatContext } from "stream-chat-react";
import "./AppLayout.css";

// A column: the banner on top, then the rest of the app. It carries `str-chat` and the theme, so
// the SDK's tokens resolve in the banner, outside ChatView.
export const AppLayout = ({
  banner,
  children,
}: PropsWithChildren<{ banner: ReactNode }>) => {
  const { theme } = useChatContext();

  return (
    <div className={`str-chat ${theme} cookbook-app-layout`}>
      {banner}
      <div className="cookbook-app-layout__body">{children}</div>
    </div>
  );
};

Pass it the banner, and render the rest of the app, with ChatView, as its children:

src/App.tsx
import type { PropsWithChildren } from "react";
import { Chat } from "stream-chat-react";
import { AppLayout } from "./AppLayout";
import { SystemNotificationBanner } from "./SystemNotificationBanner";

export const App = ({ children }: PropsWithChildren) => (
  <Chat client={client}>
    <AppLayout banner={<SystemNotificationBanner />}>{children}</AppLayout>
  </Chat>
);

The banner takes the height it needs, and the body, with ChatView in it, takes the rest. AppLayout needs the full height of the page, which the base app's chat.css gives html, body and #root. The connection banner mounts the same way: <AppLayout banner={<ConnectionStatusBanner />}>.

Publishing your own system notifications

addSystemNotification from useNotificationApi() publishes one. It adds the system tag and no panel tags, so the banner shows it and no NotificationList does. It takes the same fields as addNotification, except targetPanels, and returns the notification's id:

src/MaintenanceNotice.tsx
import { useRef } from "react";
import { Button, useNotificationApi } from "stream-chat-react";

export const MaintenanceNotice = () => {
  const { addSystemNotification, removeNotification } = useNotificationApi();
  const notificationId = useRef<string | null>(null);

  const show = () => {
    notificationId.current = addSystemNotification({
      // No duration, so it stays until removed.
      duration: 0,
      emitter: "MaintenanceNotice",
      message: "Maintenance in 10 minutes",
      severity: "warning",
      type: "app:maintenance:upcoming",
    });
  };

  const hide = () => {
    if (notificationId.current) removeNotification(notificationId.current);
    notificationId.current = null;
  };

  return (
    <>
      <Button appearance="outline" onClick={show} size="sm" variant="secondary">
        Announce maintenance
      </Button>
      <Button appearance="ghost" onClick={hide} size="sm" variant="secondary">
        Clear notice
      </Button>
    </>
  );
};

tags you pass are added to the system tag. message is shown as it is, so translate it before you publish it. Notifications UI covers the notification panels, the display filter and translating notifications.

How it works

Info:

Example

The user's laptop loses its Wi-Fi, with SystemNotificationBanner mounted in AppLayout:

  1. Chat sees that the device has no network and publishes a system notification, system:network:connection:lost, with the message "Waiting for network…".
  2. useSystemNotifications() returns it. SystemNotificationBanner renders the bar with the icon for its severity, and the bar slides in. <NotificationAnnouncer/> reads the message to screen readers.
  3. The Wi-Fi comes back, but the socket to Stream isn't up yet. The bar reads "Reconnecting…".
  4. The client reconnects, and Chat removes the notification. The banner keeps it on screen until the slide-out animation ends, then renders nothing. With reduced motion on, it goes at once.

A notification published with addSystemNotification, such as the maintenance notice, shows in the same bar until it's removed.