Info:
This is release candidate documentation for Stream Chat React SDK v15. For the latest stable version, see the latest version (v14).

Dialogs

A dialog is anything that opens on top of the UI: the actions menu on a message, the reaction selector, the attachment selector's menu, a delete confirmation. The SDK opens all of them through dialog managers. Your own menus, popups and modals can use the same system, and then they get the same focus handling, the same dismissal on Escape and outside clicks, and the same stacking.

Dialog managers

A dialog manager keeps track of a set of dialogs: which ones exist, which are open, and in what order they opened. It renders the open ones into a portal of its own, and closes them when the user clicks outside them.

Info:

Example

When a user opens the actions menu on a message:

  1. The menu button registers a dialog on the nearest manager, the message list's.
  2. Clicking the button opens the dialog. The manager adds it to its open dialogs and renders the menu into the list's portal, next to the button.
  3. A click outside the menu closes it, and so does Escape.

The SDK creates these managers:

Manager Created by Dialogs that open on it
The modal manager Chat every GlobalModal, including the SDK's own: delete confirmations, poll creation, channel details
One per message list each MessageList and VirtualizedMessageList the message actions menu, the reaction selector, the list of who reacted
One per composer each MessageComposer the attachment selector menu, the notice shown when the microphone is blocked
ChatView's manager ChatView, inside its .str-chat root dialogs opened by the views' content that no closer manager claims, such as a channel-list row's actions
One per open modal each GlobalModal menus and popups opened inside that modal, so they render above it

A dialog opens on the nearest manager above the component that opens it. A dialog opened inside Chat but outside all of the other surfaces opens on the modal manager.

The modal manager's id is exported as modalDialogManagerId. While it has an open dialog, Chat shows notifications inside the modal instead of behind it. Displaying notifications explains how notifications are routed to panels.

Your own manager

To give a part of your app its own manager, wrap it in DialogManagerProvider. Its closeOnClickOutside prop sets whether a click outside closes the open dialogs; it is true by default.

import type { PropsWithChildren } from "react";
import { DialogManagerProvider, useChatContext } from "stream-chat-react";

export const AppToolbar = ({ children }: PropsWithChildren) => {
  const { theme } = useChatContext();

  return (
    // The SDK's CSS variables are set on `.str-chat`, so the dialogs need it as an ancestor.
    <div className={`str-chat ${theme} app-toolbar`}>
      <DialogManagerProvider closeOnClickOutside={false} id="app-toolbar">
        {children}
      </DialogManagerProvider>
    </div>
  );
};

A manager with an id is registered under it. Any component can then reach it by passing that id as dialogManagerId to useDialog, useDialogIsOpen or DialogAnchor, even from outside the manager's subtree. A manager without an id is reachable only from inside it. ChatView takes the id for its manager as its dialogManagerId prop.

Opening a dialog

useDialog({ id }) registers a dialog on the nearest manager and returns its controls:

  • open() opens it.
  • close() closes it.
  • toggle(closeAll?) opens or closes it. With closeAll set to true, opening it closes the manager's other dialogs.
  • remove() removes it from the manager. A dialog is removed for you when the component that registered it unmounts.

useDialogIsOpen(id) subscribes to whether the dialog is open. Use it for aria-expanded and anything else that renders differently while the dialog is open.

This button opens a help popup below itself:

import { useState } from "react";
import { DialogAnchor, useDialog, useDialogIsOpen } from "stream-chat-react";

const helpDialogId = "help-dialog";

export const HelpButton = () => {
  const [button, setButton] = useState<HTMLButtonElement | null>(null);
  const dialog = useDialog({ id: helpDialogId });
  const isOpen = useDialogIsOpen(helpDialogId);

  return (
    <>
      <button
        aria-expanded={isOpen}
        onClick={() => dialog.toggle()}
        ref={setButton}
      >
        Help
      </button>
      <DialogAnchor
        id={helpDialogId}
        placement="bottom-start"
        referenceElement={button}
        trapFocus
      >
        <div className="help-dialog">
          <p>Press Enter to send a message and Shift+Enter for a new line.</p>
          <button onClick={() => dialog.close()}>Got it</button>
        </div>
      </DialogAnchor>
    </>
  );
};

Rendered inside ChatView, for example in a channel header, the popup opens on ChatView's manager.

useDialog and DialogAnchor have to resolve to the same manager. Here they do, because they're called in the same component. When the button and the dialog sit under different managers, pass the same dialogManagerId to both.

Other hooks for the same state:

Hook Returns
useDialogIsTopmost(id) whether the dialog is the one opened last among the open ones
useOpenedDialogCount() how many dialogs the nearest manager has open
useModalDialog(id) the controls for a dialog on the modal manager
useModalDialogIsOpen(id) whether a dialog on the modal manager is open
useDialogManager({ dialogManagerId }) { dialogManager }: the nearest manager, or the one with that id

useDialogIsOpen and useDialogIsTopmost take a manager id as their second argument, and useOpenedDialogCount as { dialogManagerId }, to read a manager other than the nearest one.

Anchors

DialogAnchor renders a dialog next to another element, its referenceElement, and only while the dialog is open. It positions the dialog with Floating UI.

Prop Default What it does
id The dialog's id, the same one passed to useDialog.
referenceElement null The element the dialog is placed against.
placement 'auto' A Floating UI placement such as 'top-start', or 'auto', 'auto-start' or 'auto-end'.
allowFlip true Lets the dialog flip to the opposite side when it doesn't fit.
offset The distance from the reference element: a number, or an object with mainAxis, crossAxis and alignmentAxis.
updatePositionOnContentResize false Repositions the dialog when its content changes size.
updateKey Repositions the dialog whenever this value changes.
closeOnClickOutside Overrides the manager's outside-click policy for this dialog.
closeOnEscape true Closes the dialog on Escape.
closeTransitionMs 0 Keeps the dialog mounted this long after it closes, so a CSS exit animation can play.
trapFocus Keeps focus inside the dialog, and gives it role="dialog" and aria-modal.
focus true Moves focus into the dialog when it opens.
dialogManagerId Opens the dialog on the manager registered under this id instead of the nearest one.

Any other prop is passed to the dialog's div, which has the class str-chat__dialog-contents. Two data attributes on it are there for CSS:

  • data-str-chat-dialog-state is open while the dialog is open, and closing during closeTransitionMs.
  • data-str-chat-placement holds the placement the dialog ended up with, for example top-start.

This popup stays open on outside clicks, and keeps itself mounted for 180 ms after it closes so it can fade out:

import { useState } from "react";
import { DialogAnchor, useDialog } from "stream-chat-react";

const tipDialogId = "formatting-tip";

export const FormattingTip = () => {
  const [button, setButton] = useState<HTMLButtonElement | null>(null);
  const dialog = useDialog({ id: tipDialogId });

  return (
    <>
      <button onClick={() => dialog.toggle()} ref={setButton}>
        Formatting
      </button>
      <DialogAnchor
        className="formatting-tip"
        closeOnClickOutside={false}
        closeTransitionMs={180}
        id={tipDialogId}
        placement="top-start"
        referenceElement={button}
      >
        Wrap text in *asterisks* to make it bold.
      </DialogAnchor>
    </>
  );
};
.formatting-tip[data-str-chat-dialog-state="closing"] {
  opacity: 0;
  transition: opacity 180ms;
}

Ready-made dialogs

The SDK builds its own dialogs from a few components, and you can use them too:

  • Callout is an anchored, dismissible popup with a close button, for hints and instructions. It takes the DialogAnchor props and an onClose callback.
  • ContextMenu is an anchored menu with items, submenus and keyboard navigation. The message actions menu is one.
  • Prompt, Alert and Viewer lay out the content of a modal. Prompt asks for input, Alert asks the user to confirm something risky, and Viewer shows content without changing it. Prompt and Viewer have Root, Header, Body and Footer parts; Alert has Root, Header and Actions.

To change how every SDK menu behaves, override ContextMenu through WithComponents. This override keeps every menu open on outside clicks:

import type { PropsWithChildren } from "react";
import { ContextMenu, WithComponents } from "stream-chat-react";
import type { ContextMenuProps } from "stream-chat-react";

const MenuThatStaysOpen = (props: ContextMenuProps) => (
  <ContextMenu {...props} closeOnClickOutside={false} />
);

export const AppComponents = ({ children }: PropsWithChildren) => (
  <WithComponents overrides={{ ContextMenu: MenuThatStaysOpen }}>
    {children}
  </WithComponents>
);

To change only what a menu renders inside it, such as its header or its submenus, override ContextMenuContent instead. ContextMenu then keeps the placement, dismissal and focus handling. Overriding components explains WithComponents.

Modals

GlobalModal renders a modal on the modal manager from Chat. You control it with open, and it calls onClose when the user closes it with Escape, a click on the overlay or a close button. onCloseAttempt can refuse a close by returning false. When modals stack, only the one opened last takes focus and responds to Escape.

import { useState } from "react";
import { GlobalModal, Prompt } from "stream-chat-react";

export const RenameButton = () => {
  const [open, setOpen] = useState(false);
  const close = () => setOpen(false);

  return (
    <>
      <button onClick={() => setOpen(true)}>Rename</button>
      <GlobalModal onClose={close} open={open}>
        <Prompt.Root>
          <Prompt.Header close={close} title="Rename channel" />
          <Prompt.Body>
            <input aria-label="Channel name" />
          </Prompt.Body>
          <Prompt.Footer>
            <Prompt.FooterControls>
              <Prompt.FooterControlsButtonSecondary onClick={close}>
                Cancel
              </Prompt.FooterControlsButtonSecondary>
              <Prompt.FooterControlsButtonPrimary onClick={close}>
                Save
              </Prompt.FooterControlsButtonPrimary>
            </Prompt.FooterControls>
          </Prompt.Footer>
        </Prompt.Root>
      </GlobalModal>
    </>
  );
};

Inside a modal, useModalContext().close() closes it from any component. The props are listed on Modal.

The SDK renders its own modals through the Modal override key. Replace it to change every SDK modal at once: delete confirmations, bounced messages, poll creation and poll actions, the attachment selector's dialogs, channel details and the image gallery. The SDK passes each modal a className of its own, such as str-chat__message-bounce-modal, so keep it next to yours:

import type { PropsWithChildren } from "react";
import {
  Channel,
  GlobalModal,
  MessageComposer,
  WithComponents,
} from "stream-chat-react";
import type { ModalProps } from "stream-chat-react";

const AppModal = ({ className, ...props }: PropsWithChildren<ModalProps>) => (
  <GlobalModal {...props} className={`app-modal ${className ?? ""}`} />
);

export const App = () => (
  <WithComponents overrides={{ Modal: AppModal }}>
    <Channel channel={channel}>
      <MessageComposer />
    </Channel>
  </WithComponents>
);

Used in

Pitfalls

  • The button and the dialog resolve to different managers. useDialog in one component and DialogAnchor in another, under different managers, register two separate dialogs, and the button opens one that never renders. Call both in one component, or pass the same dialogManagerId to both.
  • Reading isOpen from the controls. The object useDialog returns doesn't re-render your component when the dialog opens or closes. Read the open state with useDialogIsOpen.
  • A reference element from useRef. ref.current is null during the first render and nothing re-renders when it's set, so the dialog can end up with no element to sit next to. Keep the element in state with a callback ref, as the examples do.
  • Dialogs outside .str-chat. Dialog styles read the SDK's CSS variables, which are set on .str-chat elements: the roots of ChatView, Channel and each modal, and the channel list's theme scope outside ChatView. A popup opened on the modal manager outside them renders without those variables. Give it a manager inside an element with the str-chat class, as in Your own manager.
  • Outside Chat. Every dialog hook needs a manager, and Chat provides the outermost one. Outside Chat and any DialogManagerProvider, useDialog throws.