Dialog

Dialogs inform users about a task and can contain critical information, require decisions, or involve multiple tasks.

Give FeedbackWAI-ARIABundle Size
'use client';
import * as React from 'react';
import { Dialog } from '@base_ui/react/Dialog';
import classes from './styles.module.css';

export default function UnstyledDialogIntroduction() {
  return (
    <Dialog.Root>
      <Dialog.Trigger className={classes.trigger}>Subscribe</Dialog.Trigger>
      <Dialog.Backdrop className={classes.backdrop} />
      <Dialog.Popup className={classes.popup}>
        <Dialog.Title className={classes.title}>Subscribe</Dialog.Title>
        <Dialog.Description>
          Enter your email address to subscribe to our newsletter.
        </Dialog.Description>
        <input
          className={classes.textfield}
          type="email"
          aria-label="Email address"
          placeholder="name@example.com"
        />
        <div className="controls">
          <Dialog.Close className={classes.close}>Subscribe</Dialog.Close>
          <Dialog.Close className={classes.close}>Cancel</Dialog.Close>
        </div>
      </Dialog.Popup>
    </Dialog.Root>
  );
}

Installation

Base UI components are all available as a single package.

npm install @base_ui/react

Once you have the package installed, import the component.

import { Dialog } from '@base_ui/react/Dialog';

Anatomy

Dialogs are implemented using a collection of related components:

<Dialog.Root>
  <Dialog.Trigger />
 
  <Dialog.Backdrop />
 
  <Dialog.Popup>
    <Dialog.Title />
    <Dialog.Description />
    <Dialog.Close />
  </Dialog.Popup>
</Dialog.Root>

Dialogs can be either modal (rendering the rest of the page inert) or non-modal. A non-modal dialog can be used to implement tool windows.

The modal prop of the <Dialog.Root> controls this. By default Dialogs are modal.

<Dialog.Root modal={false}>{/* ... */}</Dialog.Root>

To make the Dialog fully modal, you must have a Backdrop component and style it so it covers the entire viewport, blocking pointer interaction with other elements on the page.

Closing the dialog

The default way to close the dialog is clicking on the <Dialog.Close> component. Dialogs also close when the user clicks outside of them or presses the Esc key.

Closing on outside click can be disabled with a dismissible prop on the Dialog.Root:

<Dialog.Root dismissible={false}>{/* ... */}</Dialog.Root>

Controlled vs. uncontrolled behavior

The simplest way to control the visibility of the dialog is to use the <Dialog.Trigger> and <Dialog.Close> components.

You can set the initial state with the defaultOpen prop.

<Dialog.Root>
  <Dialog.Trigger>Open</Dialog.Trigger>
  <Dialog.Popup>
    <Dialog.Title>Demo dialog</Dialog.Title>
    <Dialog.Close>Close</Dialog.Close>
  </Dialog.Popup>
</Dialog.Root>

Doing so ensures that the accessibity attributes are set correctly so that the trigger button is approriately announced by assistive technologies.

If you need to control the visibility programmatically from the outside, use the value prop. You can still use the <Dialog.Trigger> and <Dialog.Close> components (though it's not necessary), but you need to make sure to create a handler for the onOpenChange event and update the state manually.

const [open, setOpen] = React.useState(false);
 
return (
  <Dialog.Root open={open} onOpenChange={setOpen}>
    <Dialog.Trigger>Open</Dialog.Trigger>
    <Dialog.Popup>
      <Dialog.Title>Demo dialog</Dialog.Title>
      <Dialog.Close>Close</Dialog.Close>
    </Dialog.Popup>
  </Dialog.Root>
);

Nested dialogs

A dialog can open another dialog. At times, it may be useful to know how may open sub-dialogs a given dialog has. One example of this could be styling the bottom dialog in a way they appear below the top-most one.

The number of open child dialogs is present in the data-nested-dialogs attribute and in the --nested-dialogs CSS variable on the <Dialog.Popup> component.

'use client';
import * as React from 'react';
import { Dialog as BaseDialog } from '@base_ui/react/Dialog';
import { styled } from '@mui/system';

export default function NestedDialogs() {
  return (
    <BaseDialog.Root>
      <Trigger>Open</Trigger>
      <Backdrop />
      <Popup>
        <Title>Dialog 1</Title>
        <Controls>
          <BaseDialog.Root>
            <Trigger>Open Nested</Trigger>
            <Backdrop />
            <Popup>
              <Title>Dialog 2</Title>
              <Controls>
                <BaseDialog.Root>
                  <Trigger>Open Nested</Trigger>
                  <Backdrop />
                  <Popup>
                    <Title>Dialog 3</Title>
                    <Controls>
                      <Close>Close</Close>
                    </Controls>
                  </Popup>
                </BaseDialog.Root>
                <Close>Close</Close>
              </Controls>
            </Popup>
          </BaseDialog.Root>
          <Close>Close</Close>
        </Controls>
      </Popup>
    </BaseDialog.Root>
  );
}

const grey = {
  900: '#0f172a',
  800: '#1e293b',
  700: '#334155',
  500: '#64748b',
  300: '#cbd5e1',
  200: '#e2e8f0',
  100: '#f1f5f9',
  50: '#f8fafc',
};

const Popup = styled(BaseDialog.Popup)(
  ({ theme }) => `
  --transition-duration: 150ms;
  background: ${theme.palette.mode === 'dark' ? grey[900] : grey[50]};
  border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[100]};
  min-width: 400px;
  border-radius: 4px;
  box-shadow: rgba(0, 0, 0, 0.2) 0px 18px 50px -10px;
  position: fixed;
  top: 50%;
  left: 50%;
  font-family: IBM Plex Sans;
  padding: 16px;
  z-index: 2100;
  transform: translate(-50%, -35%) scale(0.8, calc(pow(0.95, var(--nested-dialogs))))
    translateY(calc(-30px * var(--nested-dialogs)));
  visibility: hidden;
  opacity: 0.5;
  transition:
    transform var(--transition-duration) ease-in,
    opacity var(--transition-duration) ease-in,
    visibility var(--transition-duration) step-end;

  &[data-open] {
    @starting-style {
      & {
        transform: translate(-50%, -35%) scale(0.8) translateY(0);
        opacity: 0.5;
      }
    }

    visibility: visible;
    opacity: 1;
    transform: translate(-50%, -50%) scale(calc(pow(0.95, var(--nested-dialogs))))
      translateY(calc(-30px * var(--nested-dialogs)));
    transition:
      transform var(--transition-duration) ease-out,
      opacity var(--transition-duration) ease-out,
      visibility var(--transition-duration) step-start;
  }
`,
);

const Title = styled(BaseDialog.Title)`
  font-size: 1.25rem;
`;

const Trigger = styled(BaseDialog.Trigger)(
  ({ theme }) => `
  background-color: ${theme.palette.mode === 'dark' ? grey[50] : grey[900]};
  color: ${theme.palette.mode === 'dark' ? grey[900] : grey[50]};
  padding: 8px 16px;
  border-radius: 4px;
  border: none;
  font-family:
    "IBM Plex Sans",
    sans-serif;

  &:hover {
    background-color: ${theme.palette.mode === 'dark' ? grey[200] : grey[700]};
  }
`,
);

const Close = styled(BaseDialog.Close)(
  ({ theme }) => `
  background-color: transparent;
  border: 1px solid ${theme.palette.mode === 'dark' ? grey[300] : grey[500]};
  color: ${theme.palette.mode === 'dark' ? grey[50] : grey[900]};
  padding: 8px 16px;
  border-radius: 4px;
  font-family: IBM Plex Sans, sans-serif;
  min-width: 80px;

  &:hover {
    background-color: ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
  }
`,
);

const Backdrop = styled(BaseDialog.Backdrop)`
  background-color: rgb(0 0 0 / 0.2);
  position: fixed;
  inset: 0;
  z-index: 2000;
  backdrop-filter: blur(0);
  opacity: 0;
  transition-property: opacity, backdrop-filter;
  transition-duration: 250ms;
  transition-timing-function: ease-in;

  &[data-open] {
    backdrop-filter: blur(6px);
    opacity: 1;
    transition-timing-function: ease-out;
  }

  &[data-entering] {
    backdrop-filter: blur(0);
    opacity: 0;
  }
`;

const Controls = styled('div')(
  ({ theme }) => `
  display: flex;
  flex-direction: row-reverse;
  background: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]};
  gap: 8px;
  padding: 16px;
  margin: 32px -16px -16px;
`,
);

Note that when dialogs are nested, only the bottom-most backdrop is rendered.

Animation

The <Dialog.Popup> and <Dialog.Backdrop> components support transitions on entry and exit.

CSS animations and transitions are supported out of the box. If a component has a transition or animation applied to it when it closes, it will be unmounted only after the animation finishes.

As this detection of exit animations requires an extra render, you may opt out of it by setting the animated prop on Root to false. We also recommend doing so in automated tests, to avoid asynchronous behavior and make testing easier.

Alternatively, you can use JavaScript-based animations with a library like framer-motion, React Spring, or similar. With this approach set the keepMounted to true and let the animation library control mounting and unmounting.

CSS transitions

Here is an example of how to apply a symmetric scale and fade transition with the default conditionally-rendered behavior:

<Dialog.Popup className="DialogPopup">Dialog</Dialog.Popup>
.DialogPopup {
  transition-property: opacity, transform;
  transition-duration: 0.2s;
  /* Represents the final styles once exited */
  opacity: 0;
  transform: translate(-50%, -35%) scale(0.8);
}
 
/* Represents the final styles once entered */
.DialogPopup[data-open] {
  opacity: 1;
  transform: translate(-50%, -50%) scale(1);
}
 
/* Represents the initial styles when entering */
.DialogPopup[data-entering] {
  opacity: 0;
  transform: translate(-50%, -35%) scale(0.8);
}

Styles need to be applied in three states:

'use client';
import * as React from 'react';
import { Dialog as BaseDialog } from '@base_ui/react/Dialog';
import { styled } from '@mui/system';

export default function DialogWithTransitions() {
  return (
    <BaseDialog.Root>
      <Trigger>Open</Trigger>
      <Popup>
        <Title>Animated dialog</Title>
        <BaseDialog.Description>
          This dialog uses CSS transitions on entry and exit.
        </BaseDialog.Description>
        <Controls>
          <Close>Close</Close>
        </Controls>
      </Popup>
      <Backdrop />
    </BaseDialog.Root>
  );
}

const grey = {
  900: '#0f172a',
  800: '#1e293b',
  700: '#334155',
  500: '#64748b',
  300: '#cbd5e1',
  200: '#e2e8f0',
  100: '#f1f5f9',
  50: '#f8fafc',
};

const Popup = styled(BaseDialog.Popup)(
  ({ theme }) => `
  background: ${theme.palette.mode === 'dark' ? grey[900] : grey[50]};
  border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[100]};
  min-width: 400px;
  border-radius: 4px;
  box-shadow: rgba(0, 0, 0, 0.2) 0px 18px 50px -10px;
  position: fixed;
  top: 50%;
  left: 50%;
  font-family: IBM Plex Sans;
  padding: 16px;
  z-index: 2100;
  transition-property: opacity, transform;
  transition-duration: 150ms;
  transition-timing-function: ease-in;
  opacity: 0;
  transform: translate(-50%, -35%) scale(0.8);

  &[data-open] {
    opacity: 1;
    transform: translate(-50%, -50%) scale(1);
    transition-timing-function: ease-out;
  }

  &[data-entering] {
    opacity: 0;
    transform: translate(-50%, -35%) scale(0.8);
  }
`,
);

const Backdrop = styled(BaseDialog.Backdrop)`
  background-color: rgb(0 0 0 / 0.2);
  position: fixed;
  inset: 0;
  z-index: 2000;
  backdrop-filter: blur(0);
  opacity: 0;
  transition-property: opacity, backdrop-filter;
  transition-duration: 250ms;
  transition-timing-function: ease-in;

  &[data-open] {
    backdrop-filter: blur(6px);
    opacity: 1;
    transition-timing-function: ease-out;
  }

  &[data-entering] {
    backdrop-filter: blur(0);
    opacity: 0;
  }
`;

const Title = styled(BaseDialog.Title)`
  font-size: 1.25rem;
`;

const Trigger = styled(BaseDialog.Trigger)(
  ({ theme }) => `
  background-color: ${theme.palette.mode === 'dark' ? grey[50] : grey[900]};
  color: ${theme.palette.mode === 'dark' ? grey[900] : grey[50]};
  padding: 8px 16px;
  border-radius: 4px;
  border: none;
  font-family:
    "IBM Plex Sans",
    sans-serif;

  &:hover {
    background-color: ${theme.palette.mode === 'dark' ? grey[200] : grey[700]};
  }
`,
);

const Close = styled(BaseDialog.Close)(
  ({ theme }) => `
  background-color: transparent;
  border: 1px solid ${theme.palette.mode === 'dark' ? grey[300] : grey[500]};
  color: ${theme.palette.mode === 'dark' ? grey[50] : grey[900]};
  padding: 8px 16px;
  border-radius: 4px;
  font-family: IBM Plex Sans, sans-serif;
  min-width: 80px;

  &:hover {
    background-color: ${theme.palette.mode === 'dark' ? grey[700] : grey[200]};
  }
`,
);

const Controls = styled('div')(
  ({ theme }) => `
  display: flex;
  flex-direction: row-reverse;
  background: ${theme.palette.mode === 'dark' ? grey[800] : grey[100]};
  gap: 8px;
  padding: 16px;
  margin: 32px -16px -16px;
`,
);

In newer browsers, there is a feature called @starting-style which allows transitions to occur on open for conditionally-mounted components:

/* Base UI API - Polyfill */
.DialogPopup[data-entering] {
  opacity: 0;
  transform: translate(-50%, -35%) scale(0.8);
}
 
/* Official Browser API - no Firefox support as of May 2024 */
@starting-style {
  .DialogPopup[data-open] {
    opacity: 0;
    transform: translate(-50%, -35%) scale(0.8);
  }
}

CSS animations

CSS animations can also be used, requiring only two separate declarations:

@keyframes scale-in {
  from {
    opacity: 0;
    transform: translate(-50%, -35%) scale(0.8);
  }
}
 
@keyframes scale-out {
  to {
    opacity: 0;
    transform: translate(-50%, -35%) scale(0.8);
  }
}
 
.DialogPopup {
  animation: scale-in 0.2s forwards;
}
 
.DialogPopup[data-exiting] {
  animation: scale-out 0.2s forwards;
}

JavaScript animations

The keepMounted prop lets an external library control the mounting, for example framer-motion's AnimatePresence component.

function App() {
  const [open, setOpen] = useState(false);
  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Trigger>Trigger</Dialog.Trigger>
      <AnimatePresence>
        {open && (
          <Dialog.Popup
            keepMounted
            render={
              <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} />
            }
          >
            Dialog
          </Dialog.Popup>
        )}
      </AnimatePresence>
    </Dialog.Root>
  );
}

Animation states

Four states are available as data attributes to animate the dialog, which enables full control depending on whether the popup is being animated with CSS transitions or animations, JavaScript, or is using the keepMounted prop.

Composing a custom React component

Use the render prop to override the rendered element:

<Dialog.Popup render={<MyCustomDialog />} />
// or
<Dialog.Popup render={(props) => <MyCustomDialog {...props} />} />

Accessibility

Using the <Dialog.Trigger> sets the required accessibility attributes on the trigger button. If you prefer controlling the open state differently, you need to apply these attributes on your own:

const [open, setOpen] = React.useState(false);
 
return (
  <div>
    <button
      aria-haspopup="dialog"
      aria-controls="my-popup"
      type="button"
      onClick={() => setOpen(true)}
    >
      Open
    </button>
 
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Popup id="my-popup">
        <Dialog.Title>Demo dialog</Dialog.Title>
        <Dialog.Close>Close</Dialog.Close>
      </Dialog.Popup>
    </Dialog.Root>
  </div>
);

API Reference

DialogBackdrop

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
keepMountedboolfalseIf true, the backdrop element is kept in the DOM when closed.
renderunionA function to customize rendering of the component.

DialogClose

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
renderunionA function to customize rendering of the component.

DialogDescription

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
renderunionA function to customize rendering of the component.

DialogPopup

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
containerunionThe container element to which the popup is appended to.
keepMountedboolfalseIf true, the dialog element is kept in the DOM when closed.
renderunionA function to customize rendering of the component.

DialogRoot

PropTypeDefaultDescription
animatedbooltrueIf true, the dialog supports CSS-based animations and transitions. It is kept in the DOM until the animation completes.
defaultOpenboolDetermines whether the dialog is initally open. This is an uncontrolled equivalent of the open prop.
dismissiblebooltrueDetermines whether the dialog should close when clicking outside of it.
modalbooltrueDetermines whether the dialog is modal.
onOpenChangefuncCallback invoked when the dialog is being opened or closed.
openboolDetermines whether the dialog is open.

DialogTitle

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
renderunionA function to customize rendering of the component.

DialogTrigger

PropTypeDefaultDescription
classNameunionClass names applied to the element or a function that returns them based on the component's state.
renderunionA function to customize rendering of the component.

Contents