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.
Example
When a user opens the actions menu on a message:
- The menu button registers a dialog on the nearest manager, the message list's.
- 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.
- 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. WithcloseAllset totrue, 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-stateisopenwhile the dialog is open, andclosingduringcloseTransitionMs.data-str-chat-placementholds the placement the dialog ended up with, for exampletop-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:
Calloutis an anchored, dismissible popup with a close button, for hints and instructions. It takes theDialogAnchorprops and anonClosecallback.ContextMenuis an anchored menu with items, submenus and keyboard navigation. The message actions menu is one.Prompt,AlertandViewerlay out the content of a modal.Promptasks for input,Alertasks the user to confirm something risky, andViewershows content without changing it.PromptandViewerhaveRoot,Header,BodyandFooterparts;AlerthasRoot,HeaderandActions.
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
- Actions: every action menu opens as a dialog.
- Message actions and Custom message actions, whose menu is a
ContextMenu.
Pitfalls
- The button and the dialog resolve to different managers.
useDialogin one component andDialogAnchorin another, under different managers, register two separate dialogs, and the button opens one that never renders. Call both in one component, or pass the samedialogManagerIdto both. - Reading
isOpenfrom the controls. The objectuseDialogreturns doesn't re-render your component when the dialog opens or closes. Read the open state withuseDialogIsOpen. - A reference element from
useRef.ref.currentisnullduring 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-chatelements: the roots ofChatView,Channeland each modal, and the channel list's theme scope outsideChatView. A popup opened on the modal manager outside them renders without those variables. Give it a manager inside an element with thestr-chatclass, as in Your own manager. - Outside
Chat. Every dialog hook needs a manager, andChatprovides the outermost one. OutsideChatand anyDialogManagerProvider,useDialogthrows.