# Dialog

> A React modal dialog for focused tasks: a spring-scaled card on desktop and a bottom sheet on phones, with nesting and scrolling.

- Type: React component (Overlays)
- Access: Free (the source is open to everyone)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add dialog`
- Docs: https://wingo-ui.com/components/dialog

## Interaction

Click the trigger and the page dims while the card scales up from 96% on a smooth spring; the icon tile pops in a beat later. Focus moves to the first field, Tab stays inside, and Escape, a click on the dim page or the round × close it, handing focus back to the trigger. A long body scrolls between the pinned header and footer, and a hairline fades in under the header once it scrolls and above the footer while there is more below; with scrollBehavior outside the whole card scrolls with the page instead. A dialog opened from inside another one stacks on top while the first steps back and up, and closing it brings the first forward again. When it is not dismissible, a click outside or Escape only gives the card a small bump: an action has to answer it. A shortcut, when set, toggles it from anywhere. Below 768px it opens as a bottom sheet you can drag down to close, with the actions stacked full width, 48px tall and the primary on top. With reduced motion it only fades.

## Installation

```bash
npx wingo-ui@latest add dialog
```

The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add https://wingo-ui.com/r/dialog.json`

Files:

- `components/ui/dialog.tsx`

npm dependencies: `npm i @radix-ui/react-dialog @radix-ui/react-visually-hidden lucide-react motion`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [useScrollEdges](https://wingo-ui.com/components/use-scroll-edges) ([Markdown](https://wingo-ui.com/components/use-scroll-edges.md))
- [useShortcut](https://wingo-ui.com/components/use-shortcut) ([Markdown](https://wingo-ui.com/components/use-shortcut.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { Pencil } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogClose } from "@/components/ui/dialog"

export function EditListing({ saving, onSave }: { saving: boolean; onSave: () => void }) {
  return (
    <Dialog
      trigger={
        <Button variant="soft" leftIcon={<Pencil />}>
          Edit
        </Button>
      }
      title="Edit listing"
      description="Changes show up for buyers right away."
      icon={<Pencil />}
      closeLabel="Close dialog"
      shortcut="mod+e"
      footer={
        <>
          <DialogClose asChild>
            <Button variant="soft">Cancel</Button>
          </DialogClose>
          <Button loading={saving} loadingText="Saving" onClick={onSave}>
            Save
          </Button>
        </>
      }
    >
      <p className="text-sm text-muted-foreground">Title, price, city and description</p>
    </Dialog>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultOpen?` | `boolean` | `false` | Starts open (uncontrolled). Pass open and onOpenChange instead to control it. |
| `open?` | `boolean` |  | Controlled open state. The root keeps it across the 768px breakpoint, so resizing swaps the card for the sheet and keeps it open. |
| `onOpenChange?` | `(open: boolean) => void` |  | Called when it opens or closes: the trigger, Escape, an outside click, a drag on the sheet, a close button, the shortcut. |
| `title?` | `ReactNode` | `""` | The title, also the dialog's accessible name. Always pass one: without it a visually hidden “Dialog” stands in so screen readers still get a name. The demo shows its sample while this is empty. |
| `description?` | `ReactNode` | `""` | A quieter line under the title, linked with aria-describedby. Without one the dialog opts out of the description (Radix's aria-describedby={undefined}). |
| `hideTitle?` | `boolean` | `false` | Keeps the title for screen readers only, for dialogs whose content speaks for itself (a photo, a video). |
| `closeLabel?` | `string` | `"Close"` | aria-label of the × button, e.g. “Close dialog”. The demo spells it out under the page. |
| `trigger?` | `ReactNode` |  | The element that opens it, rendered with asChild: a single element that takes a ref, like a Button. It gets aria-keyshortcuts when a shortcut is set. Leave it out and control open yourself. |
| `icon?` | `ReactNode` |  | An icon in a 40px tile before the title (above it when the header is centered), painted in the tone's soft color. It pops in on a bouncy spring as the card lands. |
| `children?` | `ReactNode` |  | The body. It scrolls on its own between the pinned header and footer (overscroll contained) and drives the hairlines. Forms inside get the first field focused on open. |
| `footer?` | `ReactNode` |  | The actions, primary last in the DOM: on the right in a row on desktop, on top when stacked. On the phone sheet they stack full width at 48px whatever size you passed. Wrap Cancel in DialogClose. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The icon tile's color: success for a confirmation, danger for a warning about data loss. Needs an icon. |
| `color?` | `string` | `""` | Any CSS color for the icon tile. Replaces the tone; the icon is mixed toward the text color so it stays readable. Leave empty to use the tone. |
| `size?` | `"sm" \| "md" \| "lg" \| "xl" \| "full"` | `"md"` | Max width 400 / 520 / 720 / 960px, always 16px clear of the screen edges. full fills the screen minus 16px on desktop (a lightbox, a long editor) and becomes a full-height sheet on phones. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"lg"` | Corners 0 / 12 / 16 / 24px (full stays 24px); the icon tile follows. Dialogs default to lg like every large surface. The phone sheet takes 0 / 16 / 24 / 28px. |
| `headerAlign?` | `"start" \| "center"` | `"start"` | start reads like a form: the icon beside the title. center reads like an announcement: the icon above a centered title, and the actions centered too. |
| `showClose?` | `boolean` | `true` | The round × in the top corner (ghost, 32px with a 44px hit area on touch). It closes even a dialog that is not dismissible. |
| `scrollBehavior?` | `"inside" \| "outside"` | `"inside"` | inside: the card stops at 85% of the screen (720px at most), the header and footer stay and the body scrolls. outside: the card grows with its content and the dimmed page scrolls instead, for long reading. The phone sheet and size full always scroll inside. |
| `dismissible?` | `boolean` | `true` | false: outside clicks, Escape, the shortcut and the sheet drag do not close it; the card gives a small bump instead and the sheet loses its handle. The × and actions (DialogClose, or setting open) still close it, e.g. a consent that must be answered. |
| `responsive?` | `boolean` | `true` | Below 768px it opens as the drawer's bottom sheet: drag to dismiss, safe-area padding, a 92% height for size full. Off, it stays a centered card on phones too. |
| `shortcut?` | `string` | `""` | A key combo in mod+k notation (⌘ on Apple devices, Ctrl elsewhere) that toggles the dialog from anywhere on the page; the trigger gets aria-keyshortcuts. Empty binds nothing. |
| `modal?` | `boolean` | `true` | false: no backdrop, no focus trap and no scroll lock; the page behind stays usable (Radix's modal), and on desktop a click on the page closes it. scrollBehavior outside needs the backdrop, so it scrolls inside then. |
| `onOpenAutoFocus?` | `(event: Event) => void` |  | Runs when it opens, before focus moves. Desktop focuses the first focusable element (put the close button last, as the dialog does); the phone sheet focuses itself so no keyboard pops up. Call event.preventDefault() and focus something yourself. |
| `onCloseAutoFocus?` | `(event: Event) => void` |  | Runs when it closes, before focus returns to the trigger. Prevent it to send focus elsewhere. |
| `container?` | `HTMLElement \| null` |  | Portal target, document.body by default. |
| `classNames?` | `Partial<Record<"overlay" \| "content" \| "header" \| "icon" \| "title" \| "description" \| "body" \| "footer" \| "close", string>>` |  | Extra classes for the inner parts, in both modes: on phones they land on the sheet's parts, which carry the same data-slot="dialog-*" names (the backdrop and the handle keep drawer-overlay / drawer-handle). |
| `className?` | `string` |  | Extra classes for the card (dialog-content), merged last. |

## Parts

### DialogRoot (component)

Compound API: holds the state and picks the mode (a Radix dialog, or the drawer's sheet below 768px). No DOM. Rendered inside another dialog's content it nests: the parent steps back while it is open. Props: { open, defaultOpen, onOpenChange, dismissible, responsive, modal, shortcut, container }.

### DialogContent (component)

The portal, the backdrop (dialog-overlay), the card (dialog-content with data-size, data-scroll-behavior, data-nested, data-child-open) and the ×. Needs a DialogTitle inside (visuallyHidden if the design has none); pass aria-describedby={undefined} when there is no description. Props: { size, radius, scrollBehavior, showClose, closeLabel, container, classNames } & Radix Dialog.Content props.

### DialogHeader (component)

The icon tile, title and description; keeps clear of the ×. Its bottom hairline fades in once the body scrolls. Without a title (and no children of its own) it renders a hidden “Dialog” title. Props: { title?, description?, hideTitle?, icon?, tone?, color?, align?: "start" | "center" }.

### DialogTitle (component)

18px semibold title (17px on the sheet), wired to aria-labelledby. Props: { visuallyHidden?: boolean } & Radix Dialog.Title props.

### DialogDescription (component)

A muted 14px description, wired to aria-describedby. Takes Radix Dialog.Description props.

### DialogIcon (component)

The 40px tone tile on its own, for custom headers. Props: { tone?, color? } & ComponentProps<"span">.

### DialogBody (component)

The scroll region between header and footer. Reports its scroll edges for the hairlines; on the sheet it scrolls first and only then lets the sheet drag. A long text with nothing focusable inside joins the Tab order (with a ring) so the arrow and Page keys scroll it. Takes div props.

### DialogFooter (component)

The actions, primary last: a right-aligned row from 640px, stacked with the primary on top below that, and 48px full-width buttons on the phone sheet. Its top hairline shows while the body has more below. Takes div props.

### DialogTrigger (component)

Opens the dialog from any element. Takes Radix Dialog.Trigger props (asChild).

### DialogClose (component)

Closes the dialog from any element, also one that is not dismissible. Takes Radix Dialog.Close props (asChild).

### useDialog (hook)

The nearest dialog's state, e.g. to close it after a form saves, or to lay out differently on the phone sheet. Signature: () => { open: boolean, setOpen: (open: boolean) => void, sheet: boolean }.

## Examples

- **Sizes**: sm to xl: 400, 520, 720 and 960px. The same content reflows to the width.
- **Long content**: Inside pins the header and footer, with hairlines that follow the scroll; outside lets the whole card scroll with the page.
- **Nested**: Pick the city from a second dialog: the address form steps back while it is open.
- **Icon and tone**: A success tile, a centered header and a share action, opened after a fake 1s request.
- **Not dismissible**: dismissible={false} and no ×: Esc and outside clicks only bump the card until you choose.
- **Full size photo preview**: size="full" keeps 16px from every edge; a full-height sheet on phones. Arrow keys page through the photos.
- **Keyboard shortcut**: shortcut="mod+e" toggles it from anywhere; the trigger shows the key and gets aria-keyshortcuts.

## See also

- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Alert Dialog](https://wingo-ui.com/components/alert-dialog) ([Markdown](https://wingo-ui.com/components/alert-dialog.md))
- [Sheet](https://wingo-ui.com/components/sheet) ([Markdown](https://wingo-ui.com/components/sheet.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [useScrollEdges](https://wingo-ui.com/components/use-scroll-edges) ([Markdown](https://wingo-ui.com/components/use-scroll-edges.md))

---

Canonical: https://wingo-ui.com/components/dialog
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
