Skip to content

Dialog

A Dialog overlays content on top of a workflow, requiring a user to take action before returning to the application.
Figma logo
Illustration showing how a Dialog is constructed
Anatomy of a Dialog
Anatomy of the Dialog component
ElementPurpose
HeaderHeader should be used in all Dialogs to maintain UX consistency. Header is pinned to the top of a Dialog and contains an optional heading, which serves as the title of the Dialog, and a × (close) action. When an illustration or graphic is used, the title should be shown below the illustration (example)
ContentContent can contain almost any element, such as text, images, videos, or other components. Data-heavy tables should be avoided due to space constraints.
FooterFooter is pinned to the bottom of a Dialog, allowing content to scroll behind it.
ActionsPrimary actions submit data or move a user to a new flow, while secondary actions typically cancel an action, close the Dialog, and return the user to the previous flow. Actions should be placed inside DialogActions (example)
  • Auto-focuses by default on the least destructive option
  • When dismissed, focus is returned to the item which was focused when the Dialog was opened
  • Focus is only allowed within the contents of the Dialog
  • Scrolling is disabled on content behind the Dialog
  • DialogHeader should be used in all Dialogs to enable the × (close) action

Similar to Actions, DialogActions is provided for organizing calls-to-action within a Dialog. It will rearrange the actions based on environment.

  • DialogActions should be placed inside DialogFooter
  • Place calls-to-action within DialogActions
  • DOM nodes should be ordered sequentially, i.e. primary, secondary, tertiary
  • Calls-to-action will be aligned to the right
  • Dialog is available in two widths: medium, the default, which is 668px, and large, which is 960px.
  • Use the medium size for confirmation Dialogs and quick greeting / celebration moments.
  • Use the large size for more complex workflows like onboarding, announcements, and cross-sell opportunities. In both sizes, the height of the Dialog grows with the content.
  • On smaller viewports (<600px/sm breakpoint), Dialog becomes full-width, and the footer and actions are pinned to the bottom.

This example contains a form. Form actions are placed within DialogActions.

This example shows a large Dialog with an illustration, which breaks the mold and requires a few modifications.

  • Don’t use DialogFooter; instead, place all content inside DialogBody
  • Use the Actions component to center CTAs
  • Heading text lives within DialogContent

Dialogs occupy the entire screen on viewports narrower than 600px (sm breakpoint).

Sample of a Dialog on a mobile browser

We use a Level 1 drop shadow to help the user discover additional content below the Dialog's viewport. Note: this functionality is built in the component by default.

Form actions are typically placed inActions within a form element, but this isn’t possible within Dialogs because of the DOM structure required for the pinned elements DialogHeader and DialogFooter.

DialogActions is provided for organizing calls-to-action within a Dialog, but we must manually associate the primary CTA to its form.

This is done in two steps:

  1. Add an id to the form element
    <form id="sample-form"></form>
  2. Add a form reference to the SubmitButton
    <SubmitButton form="sample-form">Submit</SubmitButton>
React props
NameTypeDefaultDescription
children  ReactNodeThe content of the Dialog.
onClose  funcCallback triggered when closing the Dialog. Applies to clicking the close action, clicking the backdrop, and pressing the escape key.
open  booleanfalseIf true, the Dialog will be shown.
persistent  booleanfalseBy default Dialogs behave like pseudo-pages in the application whose children are not mounted until they are opened in order to reset state and to prevent loading data unnecessarily. If persistent is set, the Dialog will mount immediately and stay mounted until it is removed by a parent element, similar to a standard component. This is useful for SEO and for keeping frequently accessed Dialogs fast and responsive.
size  mediumlargemediumDetermines the max size of the Dialog.
Navigating a Menu with a keyboard
KeysAction
escape keyClose Dialog
Tab or Shift + TabCycle back to the start or end of the Dialog

The following testing snippet(s) offer suggestions for testing the component using React Testing Library with occasional help from Jest.

Setup

Each test code block depends on a stateful Dialog component which manages the open state and renders a Button that can be used to open the Dialog.

const StatefulDialog = ({ open: defaultOpen, ...props }) => {
const [open, setOpen] = useState(defaultOpen);
return (
<>
<Button onClick={() => setOpen(true)}>Open Dialog</Button>
<Dialog {...props} open={open} onClose={() => setOpen(false)} />
</>
);
};

Example

render(
<>
<section>
<h1>Content hidden by Dialog</h1>
</section>
<StatefulDialog aria-label="Enrollment completed">
<DialogHeader heading="Enrollment completed" />
<DialogBody>
<DialogDescription>
<p>You’re all set up! Go with Gusto!</p>
</DialogDescription>
</DialogBody>
<DialogFooter>
<DialogActions>
<Button>Get started</Button>
</DialogActions>
</DialogFooter>
</StatefulDialog>
</>,
);
// Search by aria-label, not heading
const dialog = screen.getByLabelText('Enrollment completed');
// Role may also be used for more specificity:
// const dialog = screen.getByRole('dialog', {
// name: 'Enrollment completed',
// });
//
// When the dialog is closed an extra hidden option is needed:
// const dialog = screen.getByRole('dialog', {
// name: 'Enrollment completed',
// hidden: true,
// });
// Closed dialogs are not visible and their contents are not mounted
expect(dialog).not.toBeVisible();
expect(dialog).toBeEmptyDOMElement();
const openDialogButton = screen.getByRole('button', {
name: 'Open dialog',
});
userEvent.click(openDialogButton);
expect(dialog).toBeVisible();
expect(dialog).toHaveAccessibleDescription('You’re all set up! Go with Gusto!');
const heading = within(dialog).getByRole('heading', {
name: 'Enrollment completed',
});
// Content outside of the modal is hidden from the accessibility tree.
// This means that all queries will only show results inside the dialog. Searching
// for elements outside of an open dialog is discouraged, but may sometimes be needed.
// To find these elements use the hidden option
const hiddenMainContent = screen.getByRole('heading', {
level: 1,
hidden: true,
});
// Focus set on the dialog and trapped within the dialog. When the dialog is closed
// focus is returned to the opening element. Focus changes occur after a delay and
// need to be waited for.
await waitFor(() => expect(dialog).toHaveFocus());
// Use the escape key to close (preferred)
userEvent.type(dialog, '{esc}');
// Alternatively, using the close button
// const closeButton = within(dialog).getByRole('button', { name: 'Close' });
// userEvent.click(closeButton);
// Using the backdrop is discouraged and can be challenging because it has
// been removed from the accessibility tree intentionally.
// Focus will be returned to the opening element
await waitFor(() => expect(openDialogButton).toHaveFocus());
const getStartedButton = within(dialog).getByRole('button', {
name: 'Get started',
});
userEvent.click(getStartedButton);