# Dialog

> An animated dialog for React and shadcn, with alert dialogs and nested dialogs, built on Base UI.

A modal dialog and alert dialog on Base UI that rise 8px at 0.96 on a spring over a blurred scrim and leave 4px in 160ms. Nested dialogs step their parent back; alert dialogs answer an outside click with a 1.5% bump.

- Tier: Free (MIT)
- Interaction: Press
- Page: https://eased.dev/components/dialog
- Installs: @base-ui/react
- Registry: https://eased.dev/r/dialog.json

## Install

```bash
npx shadcn add https://eased.dev/r/dialog.json
```

A modal dialog on Base UI's Dialog, with an alert-dialog variant on Base UI's AlertDialog, built on Eased's overlay core.

## When to use

- A short task that needs the page to wait: create a key, rename a project, invite someone.
- A confirmation before something you can't take back: use the alert dialog (`AlertDialog*`).
- A follow-up question inside a task (discard changes?): nest an alert dialog inside the dialog; the parent steps back.

## When not to use

- Long forms or browsing: use a [Sheet](/components/sheet), which keeps the page in view.
- Reversible destructive actions: act at once and offer [Undo Pill](/components/undo-pill) instead of asking.
- A few options next to a trigger: use a [Popover](/components/popover) or a [Dropdown Menu](/components/dropdown-menu).

## Usage

```tsx rename-dialog.tsx
import { Button } from "@/components/eased/button/button";
import {
  Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger,
} from "@/components/eased/dialog/dialog";
import { Input } from "@/components/eased/input/input";

export function RenameDialog() {
  return (
    <Dialog>
      <DialogTrigger>Rename</DialogTrigger>
      <DialogContent>
        <DialogTitle>Rename project</DialogTitle>
        <DialogDescription>Links to the old name keep working.</DialogDescription>
        <DialogBody>
          <Input aria-label="Project name" defaultValue="Q3 launch plan" />
        </DialogBody>
        <DialogFooter>
          <DialogClose render={<Button size="md" />}>Cancel</DialogClose>
          <DialogClose render={<Button variant="primary" size="md" />}>Save</DialogClose>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
```

## Examples

### Alert dialog

No close button and no outside dismissal: the footer holds the choice. An outside click bumps the dialog 1.5% to say no.

```tsx
<AlertDialog>
  <AlertDialogTrigger>Revoke</AlertDialogTrigger>
  <AlertDialogContent>
    <AlertDialogTitle>Revoke “Production”?</AlertDialogTitle>
    <AlertDialogDescription>Requests signed with this key start failing within a minute.</AlertDialogDescription>
    <DialogFooter>
      <AlertDialogClose render={<Button size="md" />}>Cancel</AlertDialogClose>
      <AlertDialogClose render={<Button variant="danger-solid" size="md" onClick={revoke} />}>Revoke key</AlertDialogClose>
    </DialogFooter>
  </AlertDialogContent>
</AlertDialog>
```

### Confirm before closing (nested)

Intercept `onOpenChange` and open an alert dialog inside the content. Base UI renders one scrim; the parent steps back 16px, shrinks 5% and dims while the child is open.

```tsx
<Dialog open={open} onOpenChange={(next) => (!next && dirty ? setConfirm(true) : setOpen(next))}>
  <DialogContent>
    …
    <AlertDialog open={confirm} onOpenChange={setConfirm}>
      <AlertDialogContent>…</AlertDialogContent>
    </AlertDialog>
  </DialogContent>
</Dialog>
```

### Opened from a shortcut

Pass `instant` when a keyboard shortcut opened it: the enter is skipped (rule 84), the exit still plays.

## API reference

### Dialog / AlertDialog

Base UI's `Dialog.Root` / `AlertDialog.Root`: `open`, `defaultOpen`, `onOpenChange(open, details)`, `onOpenChangeComplete`, `modal`, `actionsRef`.

### DialogTrigger / AlertDialogTrigger

| Prop | Type | Default | Description |
|---|---|---|---|
| `variant` | `ButtonVariant` | `"secondary"` / `"danger"` | The Eased Button look. |
| `size` | `ButtonSize` | `"sm"` | |
| `edge` | `"start" \| "end"` | | Pull a ghost trigger onto the content line (R104). |
| `render` | `ReactElement` | | Bring your own trigger element. |

### DialogContent

| Prop | Type | Default | Description |
|---|---|---|---|
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | 384, 448 or 576px; never wider than the screen less 32px. |
| `closeButton` | `boolean` | `true` | The × in the top corner, its icon on the content's right edge. |
| `closeLabel` | `string` | `"Close"` | |
| `initialFocus` | Base UI | first tabbable | Where focus goes on open. |
| `finalFocus` | Base UI | the trigger | Where focus returns on close. |
| `instant` | `boolean` | `false` | Skip the enter animation. |
| `container` | `HTMLElement \| null` | `document.body` | Portal target. |
| `className` | `string` | | Appended to the popup. |

`AlertDialogContent` takes the same props minus `closeButton`, and defaults to `size="sm"`.

### DialogTitle, DialogDescription, AlertDialogTitle, AlertDialogDescription, DialogBody, DialogFooter

Title is 16px medium and labels the dialog; description is 14px secondary and describes it. Body sits 20px under the header; the footer right-aligns its buttons 24px under the body and stacks them full width under 640px, primary on top.

## Keyboard

| Key | Action |
|---|---|
| `Tab` / `Shift+Tab` | Cycles focus inside the dialog (trapped). |
| `Esc` | Closes the frontmost dialog only. |
| `Enter` | Submits the form inside, if any. |

## Accessibility

- `role="dialog"` (or `alertdialog`), `aria-modal`, named by the title and described by the description.
- Focus moves to the first tabbable element (the popup on touch, so the keyboard doesn't jump up) and returns to the trigger on close.
- The page behind is inert and scroll-locked. The close button is a 32px target named "Close".

## Motion

The dialog rises 8px from scale 0.96 on the smooth spring (no overshoot) while it fades in over 200ms; it leaves 4px down at 0.98 in 160ms on the standard curve. The scrim fades and blurs 7px in 200ms, out in 150ms. A parent under a nested dialog steps back 16px and 5% per level on the same spring and dims. Open and close are transitions, so a close mid-open turns around. An alert dialog answers an outside click with a 1.5% bump. Reduced motion: a 100ms fade, no travel, no bump.

## Notes for AI

Pick Dialog for a short blocking task and AlertDialog for an irreversible confirmation; use `DialogClose render={<Button />}` for footer buttons so they close it, and keep the primary action last in `DialogFooter`. Nest an AlertDialog inside DialogContent to confirm discarding changes.

Don't wrap it in your own portal, overlay or focus trap, and don't animate it with Motion: Base UI and overlay core already handle focus, scroll lock, stacking and motion. Don't put a Dialog inside a Popover; open it from a menu item instead.

## Related

- [Sheet](/components/sheet): the same modal behaviour from an edge, with drag to dismiss.
- [Command Palette](/components/command-palette): a dialog built around a search list.
- [Popover](/components/popover): non-modal, anchored to its trigger.

## Example

`components/eased/dialog/dialog-demo.tsx`

```tsx
"use client";

import { useId, useState, type FormEvent } from "react";
import { Button } from "../button/button";
import { Input } from "../input/input";
import { DemoFrame } from "../lib/demo-frame";
import { rowGrid, rowGridRow } from "../lib/layout";
import { Segmented } from "../segmented/segmented";
import {
  AlertDialog,
  AlertDialogClose,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogTitle,
  AlertDialogTrigger,
  Dialog,
  DialogBody,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogTitle,
  DialogTrigger,
} from "./dialog";

type Scope = "read" | "write" | "admin";
type Key = { id: string; name: string; prefix: string; scope: Scope; created: Date };

const SCOPE_LABEL: Record<Scope, string> = { read: "Read only", write: "Read, write", admin: "Admin" };

const day = (offset: number) => new Date(Date.UTC(2026, 9, 18 - offset));
const KEYS: Key[] = [
  { id: "k1", name: "Production", prefix: "nw_live_8f2c", scope: "write", created: day(41) },
  { id: "k2", name: "Staging", prefix: "nw_test_31aa", scope: "admin", created: day(12) },
  { id: "k3", name: "Metabase sync", prefix: "nw_live_c90e", scope: "read", created: day(3) },
];

const dateFmt = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });

export function DialogDemo() {
  const [keys, setKeys] = useState(KEYS);

  return (
    <DemoFrame title="API keys" meta={`${keys.length} active`} actions={<NewKeyDialog onCreate={(k) => setKeys((list) => [...list, k])} />}>
      <div className="flex min-h-0 flex-1 flex-col px-(--pad) pt-4">
        <p className="text-ui-sm text-pretty text-text-secondary">Keys act as your workspace. Revoking one stops every request that uses it.</p>
        <ul className={`${rowGrid} mt-3 -mx-2 gap-x-3 overflow-y-auto [--col:auto]`} aria-label="API keys">
          {keys.map((k) => (
            <li key={k.id} className={`${rowGridRow} h-14 rounded-control px-2 hover-fine:bg-surface-hover [transition:background-color_150ms_var(--eased-ease-standard)]`}>
              <KeyIcon />
              <span className="flex min-w-0 flex-col">
                <span className="truncate text-ui-sm font-medium text-text">{k.name}</span>
                <span className="truncate text-ui-xs text-text-muted">
                  <span className="font-mono slashed-zero">{k.prefix}…</span> · {SCOPE_LABEL[k.scope]} · {dateFmt.format(k.created)}
                </span>
              </span>
              <RevokeDialog name={k.name} onRevoke={() => setKeys((list) => list.filter((x) => x.id !== k.id))} />
            </li>
          ))}
          {keys.length === 0 && (
            <li className="col-span-full flex h-28 flex-col items-center justify-center gap-1 text-center">
              <span className="text-ui-sm text-text">No active keys</span>
              <span className="text-ui-xs text-text-muted">Create one to call the API.</span>
            </li>
          )}
        </ul>
      </div>
    </DemoFrame>
  );
}

/** The create dialog. Cancelling with a name typed asks first, in a nested alert dialog. */
function NewKeyDialog({ onCreate }: { onCreate: (k: Key) => void }) {
  const id = useId();
  const [open, setOpen] = useState(false);
  const [confirm, setConfirm] = useState(false);
  const [name, setName] = useState("");
  const [scope, setScope] = useState<Scope>("read");

  const reset = () => {
    setName("");
    setScope("read");
  };
  const dirty = name.trim().length > 0;

  const submit = (e: FormEvent) => {
    e.preventDefault();
    if (!dirty) return;
    const hex = Math.random().toString(16).slice(2, 6);
    onCreate({ id: crypto.randomUUID(), name: name.trim(), prefix: `nw_live_${hex}`, scope, created: day(0) });
    setOpen(false);
  };

  return (
    <Dialog
      open={open}
      onOpenChange={(next) => {
        // Closing with a name typed (Esc, outside click, Cancel) asks first.
        if (!next && dirty) return setConfirm(true);
        setOpen(next);
      }}
      onOpenChangeComplete={(o) => !o && reset()}
    >
      <DialogTrigger variant="primary">New key</DialogTrigger>
      <DialogContent>
        <DialogTitle>Create API key</DialogTitle>
        <DialogDescription>The secret is shown once, right after you create it.</DialogDescription>
        <form onSubmit={submit}>
          <DialogBody className="gap-5">
            <div className="flex flex-col gap-1.5">
              <label htmlFor={id} className="text-ui-sm font-medium text-text">
                Name
              </label>
              <Input id={id} value={name} onValueChange={setName} placeholder="e.g. Zapier export" autoComplete="off" />
            </div>
            <div className="flex flex-col gap-1.5">
              <span id={`${id}-scope`} className="text-ui-sm font-medium text-text">
                Permissions
              </span>
              <Segmented
                aria-labelledby={`${id}-scope`}
                className="self-start"
                options={[
                  { value: "read", label: "Read" },
                  { value: "write", label: "Write" },
                  { value: "admin", label: "Admin" },
                ]}
                value={scope}
                onValueChange={(v) => setScope(v as Scope)}
              />
            </div>
          </DialogBody>
          <DialogFooter>
            <DialogClose render={<Button size="md" />}>Cancel</DialogClose>
            <Button type="submit" variant="primary" size="md" disabled={!dirty}>
              Create key
            </Button>
          </DialogFooter>
        </form>

        <AlertDialog open={confirm} onOpenChange={setConfirm}>
          <AlertDialogContent>
            <AlertDialogTitle>Discard this key?</AlertDialogTitle>
            <AlertDialogDescription>“{name.trim()}” hasn’t been created yet.</AlertDialogDescription>
            <DialogFooter>
              <AlertDialogClose render={<Button size="md" />}>Keep editing</AlertDialogClose>
              <Button
                variant="danger-solid"
                size="md"
                onClick={() => {
                  setConfirm(false);
                  setOpen(false);
                }}
              >
                Discard
              </Button>
            </DialogFooter>
          </AlertDialogContent>
        </AlertDialog>
      </DialogContent>
    </Dialog>
  );
}

function RevokeDialog({ name, onRevoke }: { name: string; onRevoke: () => void }) {
  return (
    <AlertDialog>
      <AlertDialogTrigger variant="ghost" edge="end">
        Revoke
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogTitle>Revoke “{name}”?</AlertDialogTitle>
        <AlertDialogDescription>Requests signed with this key start failing within a minute. This can’t be undone.</AlertDialogDescription>
        <DialogFooter>
          <AlertDialogClose render={<Button size="md" />}>Cancel</AlertDialogClose>
          <AlertDialogClose render={<Button variant="danger-solid" size="md" onClick={onRevoke} />}>Revoke key</AlertDialogClose>
        </DialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );
}

function KeyIcon() {
  return (
    <span aria-hidden className="flex size-8 items-center justify-center rounded-control-sm bg-surface-sunken text-text-muted">
      <svg viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
        <circle cx="5.5" cy="10.5" r="2.75" />
        <path d="m7.5 8.5 5.5-5.5M11 5l1.5 1.5M9.5 6.5 11 8" />
      </svg>
    </span>
  );
}
```
