# Dialog (/ui/components/react/dialog)



<Playground component="dialog" />

## Usage [#usage]

```tsx
import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogBody,
  DialogFooter,
  DialogClose,
} from '@appica/ui-react/dialog'
import { Button } from '@appica/ui-react/button'
```

```tsx
<Dialog>
  <DialogTrigger render={<Button variant="outline">Invite people</Button>} />
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Invite your team</DialogTitle>
      <DialogDescription>Send an invitation by email.</DialogDescription>
    </DialogHeader>
    <DialogBody>{/* form, content, … */}</DialogBody>
    <DialogFooter>
      <DialogClose render={<Button variant="outline">Cancel</Button>} />
      <DialogClose render={<Button>Send invites</Button>} />
    </DialogFooter>
  </DialogContent>
</Dialog>
```

`Dialog` is a compound component built on [Base UI's Dialog](https://base-ui.com/react/components/dialog). The root holds the open state; the parts compose the trigger and the portalled, centered popup. By default it's **modal** (focus trapped, page behind blocked) and dismissible (an outside click or Escape closes it), and it draws a close button in the corner:

* **`DialogTrigger`** — the button that opens the dialog. Use `render` to project it onto your own [`Button`](/ui/components/react/button) (or any element).
* **`DialogContent`** — the portalled, centered popup. It draws the card, backdrop, focus trap, and corner close button, and accepts the shared **modal props** below.
* **`DialogHeader`** — groups the title and description with consistent spacing.
* **`DialogTitle`** — an `<h2>` that labels the popup (`aria-labelledby`).
* **`DialogDescription`** — a `<p>` that describes it (`aria-describedby`).
* **`DialogBody`** — the main content region. It doesn't scroll on its own — for content taller than the viewport, wrap it in a [`ScrollArea`](#scrollable-content).
* **`DialogFooter`** — the actions; stacks on small screens and right-aligns in a row from `sm` up.
* **`DialogClose`** — a button that dismisses the dialog; project it onto your own [`Button`](/ui/components/react/button) with `render`.

Use a `Dialog` for a focused task or piece of content that should interrupt the page — a form, a confirmation with detail, a media viewer. When the action is a high-stakes confirmation, reach for an [`Alert Dialog`](/ui/components/react/alert-dialog); when the overlay should slide in from an edge (or be swipeable on touch), use a [`Drawer`](/ui/components/react/drawer); for a small, non-blocking panel anchored to a control, use a [`Popover`](/ui/components/react/popover).

## Examples [#examples]

### Form dialog [#form-dialog]

The most common shape: a header, a body of [`Field`](/ui/components/react/field)s, and a footer of actions. Lay the form out in `DialogBody` and dismiss with `DialogClose` projected onto your buttons.

```tsx
import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogBody,
  DialogFooter,
  DialogClose,
} from '@appica/ui-react/dialog'
import { Button } from '@appica/ui-react/button'
import { Field, FieldLabel } from '@appica/ui-react/field'
import { Input } from '@appica/ui-react/input'
import { Textarea } from '@appica/ui-react/textarea'

export default function DialogForm() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline">Edit profile</Button>} />
      <DialogContent className="sm:w-110">
        <DialogHeader>
          <DialogTitle>Edit profile</DialogTitle>
          <DialogDescription>
            Update how you appear across the workspace. Changes are visible to your team.
          </DialogDescription>
        </DialogHeader>
        <DialogBody className="flex flex-col gap-4">
          <Field>
            <FieldLabel>Display name</FieldLabel>
            <Input defaultValue="Sarah Jenkins" />
          </Field>
          <Field>
            <FieldLabel>Email</FieldLabel>
            <Input type="email" defaultValue="sarah@appica.dev" />
          </Field>
          <Field>
            <FieldLabel>Bio</FieldLabel>
            <Textarea rows={3} defaultValue="Product designer focused on design systems and accessibility." />
          </Field>
        </DialogBody>
        <DialogFooter>
          <DialogClose render={<Button variant="soft">Cancel</Button>} />
          <DialogClose render={<Button>Save changes</Button>} />
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
```

### Sizing [#sizing]

`DialogContent` is `w-150` (600px) by default. Override the **width** with a `className` — a responsive `sm:w-100` / `sm:w-200`, a `max-w-*` cap, or a fixed `w-*`. Every dialog also carries `max-w-full`, so any width you set still shrinks to fit narrow screens. For **height**, the popup grows with its content up to the viewport; give it a fixed `h-*` only when you want a [scrollable](#scrollable-content) body that doesn't resize as content changes.

```tsx
import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogBody,
  DialogFooter,
  DialogClose,
} from '@appica/ui-react/dialog'
import { Button } from '@appica/ui-react/button'

const SIZES = [
  { label: 'Small', className: 'sm:w-100', width: '400px' },
  { label: 'Default', className: undefined, width: '600px' },
  { label: 'Large', className: 'sm:w-200', width: '800px' },
] as const

export default function DialogSizing() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-3">
      {SIZES.map(({ label, className, width }) => (
        <Dialog key={label}>
          <DialogTrigger render={<Button variant="outline">{label}</Button>} />
          <DialogContent className={className}>
            <DialogHeader>
              <DialogTitle>{label} dialog</DialogTitle>
              <DialogDescription>
                {className ? (
                  <>
                    Set on <code>DialogContent</code> with <code>className="{className}"</code> ({width}).
                  </>
                ) : (
                  <>The default width is {width}; no className needed.</>
                )}
              </DialogDescription>
            </DialogHeader>
            <DialogBody>
              <p>
                Widths are responsive — every dialog also carries <code>max-w-full</code>, so it shrinks to fit narrow
                screens instead of overflowing.
              </p>
            </DialogBody>
            <DialogFooter>
              <DialogClose render={<Button variant="outline">Close</Button>} />
            </DialogFooter>
          </DialogContent>
        </Dialog>
      ))}
    </div>
  )
}
```

### Scrollable content [#scrollable-content]

For content taller than the viewport, give `DialogContent` a fixed height and let a [`ScrollArea`](/ui/components/react/scroll-area) own the overflow between a pinned header and footer. The header and footer stay put while the body scrolls.

```tsx
import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from '@appica/ui-react/dialog'
import { Button } from '@appica/ui-react/button'
import { ScrollArea } from '@appica/ui-react/scroll-area'

const SECTIONS = [
  {
    title: '1. Acceptance of terms',
    body: 'By accessing or using the service you agree to be bound by these terms. If you are entering into this agreement on behalf of a company, you represent that you have the authority to bind that entity.',
  },
  {
    title: '2. Accounts',
    body: 'You are responsible for safeguarding the credentials you use to access the service and for any activity under your account. Notify us immediately of any unauthorized use.',
  },
  {
    title: '3. Acceptable use',
    body: 'You agree not to misuse the service: no probing, scanning, or testing the vulnerability of any system, and no interfering with or disrupting the integrity or performance of the service.',
  },
  {
    title: '4. Content',
    body: 'You retain ownership of the content you submit. By submitting content you grant us a worldwide, non-exclusive license to host and display it solely to operate and improve the service.',
  },
  {
    title: '5. Subscriptions',
    body: 'Paid plans renew automatically at the end of each billing cycle unless cancelled beforehand. Fees are non-refundable except where required by law.',
  },
  {
    title: '6. Termination',
    body: 'We may suspend or terminate your access if you breach these terms. You may stop using the service at any time; certain provisions survive termination.',
  },
  {
    title: '7. Disclaimers',
    body: 'The service is provided "as is" without warranties of any kind, whether express or implied, including merchantability, fitness for a particular purpose, and non-infringement.',
  },
  {
    title: '8. Changes',
    body: 'We may revise these terms from time to time. Material changes will be communicated in advance, and continued use after they take effect constitutes acceptance.',
  },
]

export default function DialogScrollable() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline">Review terms</Button>} />
      <DialogContent className="h-138 sm:w-130">
        <DialogHeader>
          <DialogTitle>Terms of Service</DialogTitle>
          <DialogDescription>Last updated June 27, 2026</DialogDescription>
        </DialogHeader>
        <ScrollArea className="min-h-0 flex-1">
          <div className="flex flex-col gap-5 px-6 pb-2">
            {SECTIONS.map((section) => (
              <section key={section.title} className="flex flex-col gap-1.5">
                <h3 className="text-foreground-intense font-medium">{section.title}</h3>
                <p>{section.body}</p>
              </section>
            ))}
          </div>
        </ScrollArea>
        <DialogFooter>
          <DialogClose render={<Button variant="outline">Decline</Button>} />
          <DialogClose render={<Button>Accept</Button>} />
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
```

### Nested dialogs [#nested-dialogs]

A dialog can open another dialog. When the child opens, the parent **retracts with the same animation as a close** and the child takes its place as a fresh dialog — keeping its frosted frame over the original backdrop — so the two never have to line up in size. Closing the child brings the parent back. Each dialog keeps its own state; just nest the markup.

```tsx
import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogBody,
  DialogFooter,
  DialogClose,
} from '@appica/ui-react/dialog'
import { Button } from '@appica/ui-react/button'
import { Field, FieldLabel } from '@appica/ui-react/field'
import { Input } from '@appica/ui-react/input'
import { Badge } from '@appica/ui-react/badge'
import type { ReactNode } from 'react'

function SettingRow({ label, value, action }: { label: string; value: ReactNode; action?: ReactNode }) {
  return (
    <div className="border-border-muted flex items-center justify-between gap-4 border-b py-3 last:border-b-0">
      <div className="flex min-w-0 flex-col">
        <span className="text-foreground-intense font-medium">{label}</span>
        <span className="truncate text-sm">{value}</span>
      </div>
      {action}
    </div>
  )
}

export default function DialogNested() {
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline">Account settings</Button>} />
      <DialogContent className="sm:w-110">
        <DialogHeader>
          <DialogTitle>Account & security</DialogTitle>
          <DialogDescription>Manage how you sign in and keep your account safe.</DialogDescription>
        </DialogHeader>
        <DialogBody>
          <SettingRow label="Email" value="ada@appica.dev" />
          <SettingRow
            label="Password"
            value="Last changed 3 months ago"
            action={
              <Dialog>
                <DialogTrigger render={<Button variant="soft">Change</Button>} />
                <DialogContent className="sm:w-110">
                  <DialogHeader>
                    <DialogTitle>Change password</DialogTitle>
                    <DialogDescription>Use at least 8 characters, including a number.</DialogDescription>
                  </DialogHeader>
                  <DialogBody className="flex flex-col gap-4">
                    <Field>
                      <FieldLabel>Current password</FieldLabel>
                      <Input type="password" autoComplete="new-password" defaultValue="opensesame" />
                    </Field>
                    <Field>
                      <FieldLabel>New password</FieldLabel>
                      <Input type="password" autoComplete="new-password" defaultValue="correct-horse" />
                    </Field>
                  </DialogBody>
                  <DialogFooter>
                    <DialogClose render={<Button variant="soft">Cancel</Button>} />
                    <DialogClose render={<Button>Update password</Button>} />
                  </DialogFooter>
                </DialogContent>
              </Dialog>
            }
          />
          <SettingRow label="Two-factor auth" value={<Badge variant="success">Enabled</Badge>} />
          <SettingRow label="Active sessions" value="2 devices" />
        </DialogBody>
        <DialogFooter>
          <DialogClose render={<Button>Done</Button>} />
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
```

### Controlled [#controlled]

Drive the open state yourself with `open` + `onOpenChange` (or stay uncontrolled with `defaultOpen`). Owning the state is what makes a **multi-step flow** work — this create-project wizard tracks the current step, gates **Next** until the name is filled, resets itself when the dialog closes (via `onOpenChange`), and closes programmatically from **Create** rather than a `DialogClose`. The result is handed back to the page, which swaps the trigger for a project card; delete it to start over.

```tsx
'use client'

import * as React from 'react'
import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogBody,
  DialogFooter,
  DialogClose,
} from '@appica/ui-react/dialog'
import { Button } from '@appica/ui-react/button'
import { Field, FieldLabel } from '@appica/ui-react/field'
import { Input } from '@appica/ui-react/input'
import { Textarea } from '@appica/ui-react/textarea'
import { Thumbnail } from '@appica/ui-react/thumbnail'
import { Folder, Trash } from '@appica/icons-react'

interface Project {
  name: string
  description: string
}

export default function DialogControlled() {
  const [open, setOpen] = React.useState(false)
  const [step, setStep] = React.useState(0)
  const [name, setName] = React.useState('')
  const [description, setDescription] = React.useState('')
  const [project, setProject] = React.useState<Project | null>(null)

  function handleOpenChange(next: boolean) {
    setOpen(next)
    if (!next) {
      setStep(0)
      setName('')
      setDescription('')
    }
  }

  if (project) {
    return (
      <div className="border-border bg-background flex w-full max-w-80 items-center gap-3 rounded-xl border p-3">
        <Thumbnail variant="icon-soft" size="sm">
          <Folder />
        </Thumbnail>
        <div className="flex min-w-0 flex-col">
          <span className="text-foreground-intense -mb-0.5 truncate font-medium">{project.name}</span>
          <span className="text-foreground-muted truncate text-sm">{project.description || 'No description'}</span>
        </div>
        <Button
          variant="outline"
          size="icon-sm"
          className="ms-auto shrink-0"
          aria-label={`Delete ${project.name}`}
          onClick={() => setProject(null)}
        >
          <Trash />
        </Button>
      </div>
    )
  }

  return (
    <Dialog open={open} onOpenChange={handleOpenChange}>
      <DialogTrigger render={<Button variant="outline">Create project</Button>} />
      <DialogContent className="sm:w-105">
        <DialogHeader>
          <DialogTitle>{step === 0 ? 'Name your project' : 'Add a description'}</DialogTitle>
          <DialogDescription>Step {step + 1} of 2 — you can change this later.</DialogDescription>
        </DialogHeader>
        <DialogBody>
          {step === 0 ? (
            <Field>
              <FieldLabel>Project name</FieldLabel>
              <Input autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="Orbit" />
            </Field>
          ) : (
            <Field>
              <FieldLabel>Description</FieldLabel>
              <Textarea
                autoFocus
                rows={3}
                value={description}
                onChange={(e) => setDescription(e.target.value)}
                placeholder="What is this project about?"
              />
            </Field>
          )}
        </DialogBody>
        <DialogFooter>
          {step === 0 ? (
            <>
              <DialogClose render={<Button variant="soft">Cancel</Button>} />
              <Button disabled={!name.trim()} onClick={() => setStep(1)}>
                Next
              </Button>
            </>
          ) : (
            <>
              <Button variant="soft" onClick={() => setStep(0)}>
                Back
              </Button>
              <Button
                onClick={() => {
                  setProject({ name: name.trim(), description: description.trim() })
                  handleOpenChange(false)
                }}
              >
                Create project
              </Button>
            </>
          )}
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
```

## RTL [#rtl]

Every Appica UI component supports right-to-left layouts out of the box. Set the `dir` attribute on a container (commonly your `<html>` element) so CSS logical properties resolve correctly, and wrap your tree in `DirectionProvider` so direction-aware behavior follows the same direction.

```tsx
import { DirectionProvider } from '@appica/ui-react/providers/direction-provider'

export default function RootLayout({ children }) {
  return (
    <html lang="ar" dir="rtl">
      <body>
        <DirectionProvider dir="rtl">{children}</DirectionProvider>
      </body>
    </html>
  )
}
```

The header, body, and footer flip to read right-to-left, the footer's actions mirror to the leading edge, and the corner close button moves to the opposite corner. The popup portals to `<body>`, so it follows the **document** direction — when you scope RTL to a subtree of an LTR page, set `dir="rtl"` on `DialogContent` so the popup mirrors too. For setup details and caveats, see the [RTL guide](/ui/docs/react/rtl).

<RtlPreview component="dialog" />

## API reference [#api-reference]

`Dialog` wraps [Base UI's Dialog](https://base-ui.com/react/components/dialog). Each part forwards every remaining prop to the matching Base UI primitive, so anything it accepts works here — the tables below list the Appica additions and the most common props. See the Base UI docs for the complete API.

### Dialog [#dialog]

The root. Holds the open state; renders no DOM of its own.

| Prop                      | <ColMinWidth width="200">Type</ColMinWidth> | Default | <ColMinWidth width="220">Description</ColMinWidth>            |
| ------------------------- | ------------------------------------------- | ------- | ------------------------------------------------------------- |
| `open`                    | `boolean`                                   | —       | Controlled open state. Pair with `onOpenChange`.              |
| `defaultOpen`             | `boolean`                                   | `false` | Uncontrolled initial open state.                              |
| `onOpenChange`            | <Code>(open, eventDetails) => void</Code>   | —       | Fires when the dialog opens or closes.                        |
| `modal`                   | <Code>boolean \| 'trap-focus'</Code>        | `true`  | Trap focus and block scroll/interaction with the page behind. |
| `disablePointerDismissal` | `boolean`                                   | `false` | Keep the dialog open when the backdrop is clicked.            |
| `onOpenChangeComplete`    | `(open) => void`                            | —       | Fires after the open/close transition finishes.               |
| `actionsRef`              | `RefObject`                                 | —       | Imperative handle to `close()` / `unmount()` the popup.       |

### DialogTrigger [#dialogtrigger]

The button that opens the dialog. Forwards Base UI's `Dialog.Trigger`; use `render` to project it onto your own element.

| Prop           | <ColMinWidth width="220">Type</ColMinWidth>                 | Default | Description                                          |
| -------------- | ----------------------------------------------------------- | ------- | ---------------------------------------------------- |
| `render`       | <Code>ReactElement \| (props, state) => ReactElement</Code> | —       | Render the trigger as your own element (a `Button`). |
| `nativeButton` | `boolean`                                                   | `true`  | Whether the rendered element is a native `<button>`. |
| `className`    | `string`                                                    | —       | Extra classes, merged via `tailwind-merge`.          |

### DialogContent [#dialogcontent]

The portalled, centered popup. Draws the card, backdrop, focus trap, and corner close button, and maps the underlying Base UI Portal / Backdrop / Viewport so you don't have to reach into them. Shared with [`Alert Dialog`](/ui/components/react/alert-dialog) and [`Drawer`](/ui/components/react/drawer) through the **modal props**. Defaults to a `w-150` (600px) width — override with a `className`.

| Prop            | <ColMinWidth width="170">Type</ColMinWidth> | Default   | Description                                                               |
| --------------- | ------------------------------------------- | --------- | ------------------------------------------------------------------------- |
| `closeButton`   | `boolean`                                   | `true`    | Render the × button in the corner.                                        |
| `closeLabel`    | `string`                                    | `'Close'` | Accessible label for the close button.                                    |
| `backdrop`      | `boolean`                                   | `true`    | Render the dimmed, blurred backdrop behind the popup.                     |
| `initialFocus`  | `RefObject`                                 | —         | Element to focus when the dialog opens.                                   |
| `finalFocus`    | `RefObject`                                 | —         | Element to focus when it closes (defaults to the trigger).                |
| `container`     | <Code>HTMLElement \| ref \| null</Code>     | —         | Portal target — render the popup into a different node.                   |
| `keepMounted`   | `boolean`                                   | `false`   | Keep the popup mounted in the DOM while closed.                           |
| `backdropProps` | `object`                                    | —         | Escape hatch for the backdrop element (`className`, `style`, …).          |
| `viewportProps` | `object`                                    | —         | Escape hatch for the viewport element that centers the popup.             |
| `portalProps`   | `object`                                    | —         | Escape hatch for the portal element.                                      |
| `className`     | `string`                                    | —         | Extra classes on the popup card (e.g. a wider `sm:w-*` or a fixed `h-*`). |

### DialogHeader [#dialogheader]

A layout wrapper that groups the title and description with consistent padding and spacing.

| Prop        | Type        | Default | Description                                 |
| ----------- | ----------- | ------- | ------------------------------------------- |
| `children`  | `ReactNode` | —       | Typically the title and description.        |
| `className` | `string`    | —       | Extra classes, merged via `tailwind-merge`. |

### DialogTitle [#dialogtitle]

An `<h2>` that labels the popup. Rendering one sets the popup's `aria-labelledby`.

| Prop        | Type        | Default | Description                                 |
| ----------- | ----------- | ------- | ------------------------------------------- |
| `children`  | `ReactNode` | —       | The title text.                             |
| `className` | `string`    | —       | Extra classes, merged via `tailwind-merge`. |

### DialogDescription [#dialogdescription]

A `<p>` that describes the popup. Rendering one sets the popup's `aria-describedby`.

| Prop        | Type        | Default | Description                                 |
| ----------- | ----------- | ------- | ------------------------------------------- |
| `children`  | `ReactNode` | —       | The description text.                       |
| `className` | `string`    | —       | Extra classes, merged via `tailwind-merge`. |

### DialogBody [#dialogbody]

The main content region between the header and footer. Flexes to fill the available height; it doesn't scroll on its own — wrap it in a [`ScrollArea`](#scrollable-content) for content taller than the viewport.

| Prop        | Type        | Default | Description                                 |
| ----------- | ----------- | ------- | ------------------------------------------- |
| `children`  | `ReactNode` | —       | The body content.                           |
| `className` | `string`    | —       | Extra classes, merged via `tailwind-merge`. |

### DialogFooter [#dialogfooter]

A layout wrapper for the actions. Stacks them vertically on small screens and lays them out in a right-aligned row from `sm` up.

| Prop        | Type        | Default | Description                                 |
| ----------- | ----------- | ------- | ------------------------------------------- |
| `children`  | `ReactNode` | —       | The action buttons.                         |
| `className` | `string`    | —       | Extra classes, merged via `tailwind-merge`. |

### DialogClose [#dialogclose]

A button that closes the dialog. Forwards Base UI's `Dialog.Close`; project it onto your own element with `render`.

| Prop           | <ColMinWidth width="220">Type</ColMinWidth>                 | Default | Description                                          |
| -------------- | ----------------------------------------------------------- | ------- | ---------------------------------------------------- |
| `render`       | <Code>ReactElement \| (props, state) => ReactElement</Code> | —       | Render the close control as your own element.        |
| `nativeButton` | `boolean`                                                   | `true`  | Whether the rendered element is a native `<button>`. |
| `className`    | `string`                                                    | —       | Extra classes, merged via `tailwind-merge`.          |

## Accessibility [#accessibility]

* The popup has `role="dialog"` with `aria-modal`, and `aria-labelledby` / `aria-describedby` wired automatically from `DialogTitle` and `DialogDescription` — always render a title.
* Opening **traps focus** inside the popup; **Escape** and an outside click close it (set `disablePointerDismissal` to keep it open on a backdrop click), and focus returns to the trigger.
* With `modal` (the default), scroll and interaction with the rest of the page are blocked while the dialog is open. Set `modal={false}` for a non-blocking dialog that leaves the page interactive.
* The corner close button carries an accessible label (`closeLabel`, default `"Close"`); set it when your dialog needs more specific wording.
* Popup enter/exit transitions are skipped when the user prefers reduced motion.
