Headless / Overlay

Dialog

Dialog is the headless modal. It manages the open state, traps focus inside the surface, dismisses on Escape or a scrim click, and binds the title and description for screen readers — and it styles none of it.

Loading playground…
Updated 3 days agoEdit on GitHubWeb & native

What it is

A compound component. Dialog.Root holds the open state; the parts inside read it through context. Dialog.Content renders into a Portal, wraps the surface in an Overlay scrim, and hands focus to a FocusScope. The surface itself is yours — Dialog applies the ARIA wiring and leaves the styling to the children.

Install

example.tsx
yarn add @usemotif/headless
example.tsx
import { Dialog } from '@usemotif/headless';

Anatomy

example.tsx
<Dialog.Root>
<Dialog.Trigger>
  <Button>Open</Button>
</Dialog.Trigger>
<Dialog.Content>
  <Dialog.Title>Title</Dialog.Title>
  <Dialog.Description>Supporting copy.</Dialog.Description>
  <Dialog.Close>
    <Button>Cancel</Button>
  </Dialog.Close>
</Dialog.Content>
</Dialog.Root>

API

Dialog.Root

Dialog.Root

stable
function Dialog.Root(props: DialogRootProps): JSX.Element
openboolean

Controlled open state. Pass alongside `onOpenChange`.

defaultOpenboolean= false

Initial open state for the uncontrolled mode.

onOpenChange(open: boolean) => void

Called whenever the open state changes — by trigger, close, Escape, or scrim click.

role"dialog" | "alertdialog"= "dialog"

The ARIA role. Use `alertdialog` for destructive confirmations — or reach for [AlertDialog](/headless/overlay/alert-dialog), which sets it for you.

childrenReactNode

The Trigger and Content parts.

Dialog.Trigger

Dialog.Trigger

stable
function Dialog.Trigger(props: DialogTriggerProps): JSX.Element
childrenReactElementrequired

A single element. Dialog clones it to inject the click handler, `aria-expanded`, and `aria-haspopup`.

Dialog.Content

Dialog.Content

stable
function Dialog.Content(props: DialogContentProps): JSX.Element | null
dismissOnEscapeboolean= true

Allow Escape to close the dialog.

dismissOnScrimClickboolean= true

Allow a click on the scrim to close the dialog.

exitDurationMsnumber= 0

Fallback timeout for an exit transition. Defaults to `0` — instant unmount. Set a positive value to keep the dialog rendered with `data-motif-state="exiting"` until the transition ends; pair with `exitStyle` on a child Box.

styleCSSProperties

Inline style for the surface wrapper. The a11y wiring is applied regardless.

childrenReactNode

The dialog surface — Title, Description, Close, and your content.

Dialog.Title / Dialog.Description

Dialog.Title

stable
function Dialog.Title(props: DialogTitleProps): JSX.Element
askeyof JSX.IntrinsicElements= "h2"

The element to render. Title defaults to `h2`, Description to `p`.

childrenReactNode

The title or description text. The element id is generated and bound automatically.

Dialog.Description takes the same props with an as default of "p". Both bind their generated id into the surface's aria-labelledby / aria-describedby.

Dialog.Close

Dialog.Close

stable
function Dialog.Close(props: DialogCloseProps): JSX.Element
childrenReactElementrequired

A single element. Dialog clones it to add a click handler that closes the dialog.

useDialogState

useDialogState

stable
function useDialogState(
initial?: { defaultOpen?: boolean },
): { open: boolean; setOpen: (next: boolean) => void; toggle: () => void; props: DialogControlledProps }

Imperative control for callers driving the dialog from external state — a form library, a router. Spread the returned props into Dialog.Root for controlled mode.

Dismissal

A dialog closes four ways: the Close part, Escape, a scrim click, or a controlled open change. dismissOnEscape and dismissOnScrimClick switch the middle two off independently — set both to false for a dialog that only closes through an explicit action, the pattern AlertDialog bakes in.

Accessibility

Dialog implements the WAI-ARIA modal contract. The surface carries role="dialog" and aria-modal="true". Dialog.Title and Dialog.Description generate ids that bind into aria-labelledby and aria-describedby, so a screen reader announces both the moment the dialog opens. The FocusScope inside Content moves focus in on open, keeps Tab cycling within the surface, and restores focus to the trigger on close.

Always include a Dialog.Title. It is the dialog's accessible name — without it, a screen-reader user is told a dialog opened but not what it is.

Examples

Uncontrolled — the simplest form:

example.tsx
<Dialog.Root>
<Dialog.Trigger><Button>Edit profile</Button></Dialog.Trigger>
<Dialog.Content style={{ padding: 24, background: 'var(--colors-surface-base)' }}>
  <Dialog.Title>Edit profile</Dialog.Title>
  <Dialog.Description>Update your display name.</Dialog.Description>
  <ProfileForm />
  <Dialog.Close><Button>Done</Button></Dialog.Close>
</Dialog.Content>
</Dialog.Root>

Controlled — driven by useDialogState:

example.tsx
const dialog = useDialogState();
 
<Dialog.Root {...dialog.props}>
<Dialog.Trigger><Button>New item</Button></Dialog.Trigger>
<Dialog.Content>
  <Dialog.Title>New item</Dialog.Title>
  <ItemForm onSubmit={() => dialog.setOpen(false)} />
</Dialog.Content>
</Dialog.Root>

With an exit transition:

example.tsx
<Dialog.Content exitDurationMs={200}>
<Box
  enterStyle={{ opacity: 0, scale: 0.96 }}
  exitStyle={{ opacity: 0, scale: 0.96 }}
  transition={{ property: 'all', duration: '$durations.2' }}
>
  <Dialog.Title>Animated</Dialog.Title>
</Box>
</Dialog.Content>