---
name: dialog
type: core
domain: overlays
requires: [loke-ui]
description: >
  Modal and non-modal dialogs. Dialog, DialogTrigger, DialogPortal, DialogOverlay,
  DialogContent, DialogTitle, DialogDescription, DialogClose. Focus trapping, scroll
  locking, accessible labelling, forceMount animation. modal=true default.
---

# Dialog

## Setup

Minimum working modal dialog. Every named sub-component is required for accessibility and correct behavior.

```tsx
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogOverlay,
  DialogPortal,
  DialogTitle,
  DialogTrigger,
} from "@loke/ui/dialog";

function Example() {
  return (
    <Dialog>
      <DialogTrigger>Open</DialogTrigger>
      <DialogPortal>
        <DialogOverlay />
        <DialogContent>
          <DialogTitle>Confirm changes</DialogTitle>
          <DialogDescription>Your changes will be saved.</DialogDescription>
          <DialogClose>Close</DialogClose>
        </DialogContent>
      </DialogPortal>
    </Dialog>
  );
}
```

`Dialog` defaults to `modal={true}`. `DialogOverlay` applies `RemoveScroll` to lock background scroll. `DialogPortal` renders into `document.body` via `createPortal`.

## Core Patterns

### Controlled open state

```tsx
import { useState } from "react";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogOverlay,
  DialogPortal,
  DialogTitle,
} from "@loke/ui/dialog";

function ControlledDialog() {
  const [open, setOpen] = useState(false);

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <button type="button" onClick={() => setOpen(true)}>
        Open
      </button>
      <DialogPortal>
        <DialogOverlay />
        <DialogContent>
          <DialogTitle>Settings</DialogTitle>
          <DialogDescription>Adjust your preferences.</DialogDescription>
          <button type="button" onClick={() => setOpen(false)}>
            Save
          </button>
        </DialogContent>
      </DialogPortal>
    </Dialog>
  );
}
```

When `open` is provided, `Dialog` is fully controlled. `onOpenChange` fires on Escape, overlay click, and `DialogClose` clicks.

### Non-modal dialog

```tsx
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogTitle,
  DialogTrigger,
} from "@loke/ui/dialog";

function NonModalExample() {
  return (
    <Dialog modal={false}>
      <DialogTrigger>Open panel</DialogTrigger>
      {/* No DialogPortal or DialogOverlay needed for non-modal */}
      <DialogContent>
        <DialogTitle>Side panel</DialogTitle>
        <DialogDescription>Background remains interactive.</DialogDescription>
      </DialogContent>
    </Dialog>
  );
}
```

`modal={false}` disables focus trapping and pointer event blocking. Background stays interactive. `DialogOverlay` renders `null` in non-modal mode so it can be omitted.

### Animated entry/exit with forceMount

Without `forceMount`, elements unmount immediately on close — CSS exit animations never run.

```tsx
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogOverlay,
  DialogPortal,
  DialogTitle,
  DialogTrigger,
} from "@loke/ui/dialog";

function AnimatedDialog() {
  return (
    <Dialog>
      <DialogTrigger>Open</DialogTrigger>
      <DialogPortal forceMount>
        <DialogOverlay
          forceMount
          className="data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out"
        />
        <DialogContent
          forceMount
          className="data-[state=open]:animate-slide-in data-[state=closed]:animate-slide-out"
        >
          <DialogTitle>Animated dialog</DialogTitle>
          <DialogDescription>Enters and exits with animation.</DialogDescription>
        </DialogContent>
      </DialogPortal>
    </Dialog>
  );
}
```

`forceMount` on `DialogPortal` propagates to children automatically. You can also set it individually on `DialogOverlay` and `DialogContent`. Use `data-state="open"` and `data-state="closed"` as CSS animation hooks.

## Common Mistakes

### Missing DialogTitle inside DialogContent

`DialogContent` wires `aria-labelledby` to the `DialogTitle` id. Without it, a `console.error` fires in dev and the dialog is inaccessible to screen readers in production. The error check runs in `TitleWarning` after mount.

```tsx
// Wrong — no title
<DialogContent>
  <p>Are you sure?</p>
  <DialogClose>OK</DialogClose>
</DialogContent>

// Correct
<DialogContent>
  <DialogTitle>Confirm</DialogTitle>
  <DialogDescription>Are you sure?</DialogDescription>
  <DialogClose>OK</DialogClose>
</DialogContent>
```

To visually hide the title while keeping it accessible, wrap it with `@loke/ui/visually-hidden`.

Source: `src/components/dialog/dialog.tsx` — `TitleWarning` dev check.

### Missing DialogOverlay in modal dialog

`DialogOverlay` wraps content in `RemoveScroll`, which locks background scroll. Without it, the page scrolls freely behind a modal dialog. `DialogOverlay` also provides the visual backdrop.

```tsx
// Wrong — no overlay
<DialogPortal>
  <DialogContent>...</DialogContent>
</DialogPortal>

// Correct
<DialogPortal>
  <DialogOverlay />
  <DialogContent>...</DialogContent>
</DialogPortal>
```

Source: `src/components/dialog/dialog.tsx` — `DialogOverlayImpl` uses `RemoveScroll`.

### Using Dialog for destructive confirmations

`Dialog` allows outside dismiss by default (in non-modal mode) and does not enforce cancel/action button semantics. Use `AlertDialog` for delete, discard, or any irreversible action. `AlertDialog` forces `modal={true}`, prevents outside dismiss, and auto-focuses the cancel button.

Source: `src/components/alert-dialog/alert-dialog.tsx`.

### Forgetting forceMount on animated exit

Elements controlled by `Presence` unmount immediately when `open` becomes false. Exit animations require the element to stay mounted during the animation. Set `forceMount` and drive animation via `data-state`.

```tsx
// Wrong — element unmounts before animation runs
<DialogPortal>
  <DialogOverlay className="animate-fade" />
</DialogPortal>

// Correct
<DialogPortal forceMount>
  <DialogOverlay forceMount className="data-[state=closed]:animate-fade" />
</DialogPortal>
```

Source: `src/components/presence/presence.tsx` — Presence state machine.

### Skipping DialogPortal

Without `DialogPortal`, `DialogContent` renders inline in the DOM. This causes z-index stacking and `overflow: hidden` clipping issues. Always portal modal content.

## Cross-references

- See also: [alert-dialog](../alert-dialog/SKILL.md) — for destructive confirmations
- See also: [popover](../popover/SKILL.md) — for non-blocking floating panels
- See also: [overlay-infrastructure](../overlay-infrastructure/SKILL.md) — DismissableLayer, FocusScope, Presence internals
