# Dialog

## Summary

`Dialog` is a [compound component](https://vega-ui.dev/docs/glossary/#compound-component) for modal overlays with root, trigger, and content parts. `DialogClose` turns your own button into a close button; headings and layout are plain markup you compose yourself. `DialogContent` renders a native `<dialog>` opened with `showModal()`, so the browser puts it in the top layer, makes the rest of the page inert, and draws the backdrop. It is the strongest general-purpose overlay in the family and should be chosen when modal interruption is intentional.

## Imports

```tsx
import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogClose,
  type DialogProps,
} from '@vega-ui/react';
```

## Exported Types

- `DialogProps`
- `DialogTriggerProps`
- `DialogContentProps`
- `DialogCloseProps`

## Minimal Composition

```tsx
import { useId, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const destructive = {
  '--button-background-color': 'var(--color-red-accent-500)',
  '--button-background-color-hover': 'var(--color-red-accent-600)',
  '--button-background-color-active': 'var(--color-red-accent-700)',
} as CSSProperties;

export default function MinimalComposition() {
  const titleId = useId();

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Leave workspace</Button>
      </DialogTrigger>
      <DialogContent aria-labelledby={titleId}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Heading id={titleId} as='h2' size={4} fontWeight={600}>Leave Northwind Studio?</Heading>
          <DialogClose asChild>
            <IconButton
              aria-label='Close'
              variant='secondary'
              appearance='ghost'
              style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
            >
              <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
            </IconButton>
          </DialogClose>
        </div>
        <Text size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
          You will lose access to 14 projects until an admin invites you again.
        </Text>
        <form method='dialog' style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
          <Button type='submit' value='cancel' variant='secondary' appearance='ghost'>Stay</Button>
          <Button type='submit' value='leave' style={destructive}>Leave workspace</Button>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

## Required Parts

- `Dialog`: root state and context provider
- `DialogTrigger`: open action surface
- `DialogContent`: modal content container

## Optional Parts

- `DialogClose`: closes the dialog when clicked; wraps your own button with `asChild`

## Your Own Markup

- a heading whose `id` is passed to `DialogContent` as `aria-labelledby` (or `aria-label` on `DialogContent`); the dialog must have a name
- action buttons that answer the dialog (Cancel, Confirm) inside a `<form method="dialog">`

## Composition Order

Typical composition:

1. `Dialog`
2. `DialogTrigger`
3. `DialogContent` with your heading, an optional `DialogClose`, body, and a `<form method="dialog">` for action buttons

## Variants

- Controlled open state versus trigger-driven usage
- Minimal content-only dialog versus a dialog with a header row and close button
- Fixed content dialog versus scrollable or fluid content dialog

## Migrating From 2.x

`DialogHeader`, `DialogTitle`, and `DialogCloseButton` were removed: the library keeps only parts that carry behavior plain markup cannot express. `DialogClose` is the behavior-only replacement for the close button.

- `DialogHeader` → your own layout element, for example a flex row
- `DialogTitle` → a `Heading` with an `id`, referenced by `aria-labelledby` on `DialogContent`
- `DialogCloseButton` → `DialogClose asChild` around your own button, for example an `IconButton` with an `aria-label`
- `useDialogContext()` no longer returns `contentId` or `titleId`, and `DialogTrigger` no longer sets `aria-controls`

## Edge Cases

- Nested overlays should be tested for focus restoration and stacking order.
- Long content should be scrollable without hiding the primary actions.
- The dialog must be named with `aria-labelledby` or `aria-label`.
- Keep dismissal behavior explicit when the dialog contains forms or destructive actions.
- If the content starts reading like a full-page workflow, a drawer, sheet, or routed screen may be clearer than a large dialog.

## Common Mistakes

- Leaving `DialogContent` without `aria-labelledby` or `aria-label`.
- Putting critical page content into a dialog without a usable mobile layout.
- Failing to test dialog behavior inside other overlays.
- Treating long dialog content as static without checking scroll and action visibility.

## Examples

### Basic

#### Basic: confirm before deleting a file

The Cancel and Delete buttons live in a `<form method="dialog">`, so either one closes the dialog and its `value` becomes the dialog's `returnValue`. The page reads it in `onClose`, after the close transition, and only then removes the file; `Escape` and the close button leave `returnValue` empty.

```tsx
import { useId, useState, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const destructive = {
  '--button-background-color': 'var(--color-red-accent-500)',
  '--button-background-color-hover': 'var(--color-red-accent-600)',
  '--button-background-color-active': 'var(--color-red-accent-700)',
} as CSSProperties;

export default function ConfirmAction() {
  const titleId = useId();
  const descriptionId = useId();
  const [deleted, setDeleted] = useState(false);

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        minHeight: 72,
        padding: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      {deleted ? (
        <>
          <Text size={3} role='status' style={{ color: 'var(--label-secondary)' }}>Q3 board deck.pdf moved to trash.</Text>
          <Button size='sm' variant='secondary' appearance='ghost' onClick={() => setDeleted(false)}>Undo</Button>
        </>
      ) : (
        <>
          <div style={{ display: 'grid', minWidth: 0 }}>
            <Text size={3} fontWeight={500}>Q3 board deck.pdf</Text>
            <Text size={2} style={{ color: 'var(--label-secondary)' }}>4.2 MB · Edited yesterday</Text>
          </div>
          <Dialog>
            <DialogTrigger asChild>
              <Button size='sm' variant='secondary' appearance='ghost'>Delete</Button>
            </DialogTrigger>
            <DialogContent
              aria-labelledby={titleId}
              aria-describedby={descriptionId}
              onClose={(event) => setDeleted(event.currentTarget.returnValue === 'delete')}
            >
              <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
                <Heading id={titleId} as='h2' size={4} fontWeight={600}>Delete Q3 board deck.pdf?</Heading>
                <DialogClose asChild>
                  <IconButton
                    aria-label='Close'
                    variant='secondary'
                    appearance='ghost'
                    style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
                  >
                    <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
                  </IconButton>
                </DialogClose>
              </div>
              <Text id={descriptionId} size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
                The file stays in trash for 30 days. Shared links stop working right away.
              </Text>
              <form method='dialog' style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
                <Button type='submit' value='cancel' variant='secondary' appearance='ghost'>Cancel</Button>
                <Button type='submit' value='delete' style={destructive}>Delete file</Button>
              </form>
            </DialogContent>
          </Dialog>
        </>
      )}
    </div>
  );
}
```

### Controlled/Stateful

#### Controlled/Stateful: one dialog for many rows

Each row opens the same controlled dialog with its own invoice. The parent owns both `open` and the selected row, and the native `<dialog>` returns focus to the button that opened it.

```tsx
import { useId, useState, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  Heading,
  Icon,
  IconButton,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const invoices = [
  { number: 'INV-2041', customer: 'Acme Logistics', amount: '$12,480.00', due: 'Nov 14, 2026' },
  { number: 'INV-2042', customer: 'Blue Harbor Cafe', amount: '$860.50', due: 'Nov 21, 2026' },
  { number: 'INV-2043', customer: 'Kestrel Robotics', amount: '$4,125.00', due: 'Dec 1, 2026' },
];

export default function ControlledOpenState() {
  const titleId = useId();
  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState(invoices[0]);

  const openInvoice = (invoice: typeof invoices[number]) => {
    setSelected(invoice);
    setOpen(true);
  };

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-6)', width: '100%' }}>
      <ul
        style={{
          display: 'grid',
          margin: 0,
          padding: 'var(--spacing-2) var(--spacing-8)',
          listStyle: 'none',
          background: 'var(--surface-primary)',
          border: '1px solid var(--separator-opaque)',
          borderRadius: 'var(--radius-5)',
        }}
      >
        {invoices.map((invoice, index) => (
          <li
            key={invoice.number}
            style={{
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'space-between',
              gap: 'var(--spacing-6)',
              paddingBlock: 'var(--spacing-5)',
              borderBlockStart: index > 0 ? '1px solid var(--separator-opaque)' : undefined,
            }}
          >
            <div style={{ display: 'grid', minWidth: 0 }}>
              <Text size={3} fontWeight={500}>{invoice.customer}</Text>
              <Text size={2} style={{ color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{invoice.number} · {invoice.amount}</Text>
            </div>
            <Button size='sm' variant='secondary' appearance='ghost' onClick={() => openInvoice(invoice)}>View</Button>
          </li>
        ))}
      </ul>
      <Text size={2} style={{ color: 'var(--label-secondary)' }}>
        Dialog: {open ? `open, showing ${selected.number}` : 'closed'}
      </Text>

      <Dialog open={open} onChangeOpen={setOpen}>
        <DialogContent aria-labelledby={titleId}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={titleId} as='h2' size={4} fontWeight={600}>{selected.number}</Heading>
            <DialogClose asChild>
              <IconButton
                aria-label='Close'
                variant='secondary'
                appearance='ghost'
                style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
              >
                <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
              </IconButton>
            </DialogClose>
          </div>
          <dl style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: 'var(--spacing-4) var(--spacing-12)', margin: 'var(--spacing-8) 0 0' }}>
            <dt><Text size={3} style={{ color: 'var(--label-secondary)' }}>Customer</Text></dt>
            <dd style={{ margin: 0 }}><Text size={3}>{selected.customer}</Text></dd>
            <dt><Text size={3} style={{ color: 'var(--label-secondary)' }}>Amount</Text></dt>
            <dd style={{ margin: 0 }}><Text size={3} style={{ fontVariantNumeric: 'tabular-nums' }}>{selected.amount}</Text></dd>
            <dt><Text size={3} style={{ color: 'var(--label-secondary)' }}>Due</Text></dt>
            <dd style={{ margin: 0 }}><Text size={3}>{selected.due}</Text></dd>
          </dl>
        </DialogContent>
      </Dialog>
    </div>
  );
}
```

#### Controlled/Stateful: keep the dialog open while there are unsaved changes

Every close request (`Escape`, the close button, Cancel) goes through `onChangeOpen(false)`. While the note has unsaved edits the parent answers the first request with a warning instead of closing; a second request discards the draft.

```tsx
import { useId, useState, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  HelperText,
  Icon,
  IconButton,
  Label,
  Text,
  TextArea,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

export default function UnsavedChangesGuard() {
  const titleId = useId();
  const fieldId = useId();
  const warningId = useId();
  const [open, setOpen] = useState(false);
  const [saved, setSaved] = useState('Call back after the Friday standup.');
  const [draft, setDraft] = useState(saved);
  const [confirming, setConfirming] = useState(false);

  const dirty = draft !== saved;

  const changeOpen = (next: boolean) => {
    if (next) {
      setDraft(saved);
      setConfirming(false);
      setOpen(true);
      return;
    }
    if (dirty && !confirming) {
      setConfirming(true);
      return;
    }
    setOpen(false);
  };

  const save = () => {
    setSaved(draft);
    setOpen(false);
  };

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        padding: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      <div style={{ display: 'grid', minWidth: 0 }}>
        <Text size={2} style={{ color: 'var(--label-secondary)' }}>Note for Maria Chen</Text>
        <Text size={3} style={{ overflowWrap: 'anywhere' }}>{saved}</Text>
      </div>
      <Dialog open={open} onChangeOpen={changeOpen}>
        <DialogTrigger asChild>
          <Button size='sm' variant='secondary' appearance='ghost'>Edit</Button>
        </DialogTrigger>
        <DialogContent aria-labelledby={titleId}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={titleId} as='h2' size={4} fontWeight={600}>Note for Maria Chen</Heading>
            <DialogClose asChild>
              <IconButton
                aria-label='Close'
                variant='secondary'
                appearance='ghost'
                style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
              >
                <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
              </IconButton>
            </DialogClose>
          </div>
          <div style={{ display: 'grid', gap: 'var(--spacing-3)', marginBlockStart: 'var(--spacing-8)' }}>
            <Label htmlFor={fieldId}>Note</Label>
            <TextArea
              id={fieldId}
              rows={4}
              fullWidth
              value={draft}
              aria-describedby={confirming ? warningId : undefined}
              onChange={(event) => {
                setDraft(event.currentTarget.value);
                setConfirming(false);
              }}
            />
            {confirming && (
              <HelperText id={warningId} role='status'>You have unsaved changes. Close again to discard them.</HelperText>
            )}
          </div>
          <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
            <DialogClose asChild>
              <Button variant='secondary' appearance='ghost'>{confirming ? 'Discard' : 'Cancel'}</Button>
            </DialogClose>
            <Button disabled={!dirty} onClick={save}>Save</Button>
          </div>
        </DialogContent>
      </Dialog>
    </div>
  );
}
```

### Form/Integration

#### Form/Integration: rename form with native validation

A `<form method="dialog">` with `required` and `pattern` fields: the browser blocks invalid submits, and a valid one runs `onSubmit` (read with `FormData`) and then closes the dialog. Cancel is `DialogClose` around a `type="button"` so it does not submit the form.

```tsx
import { useId, useState, type CSSProperties, type FormEvent } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  HelperText,
  Icon,
  IconButton,
  Label,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

export default function FormInModal() {
  const titleId = useId();
  const nameId = useId();
  const slugId = useId();
  const slugHintId = useId();
  const [project, setProject] = useState({ name: 'Mobile banking app', slug: 'mobile-banking' });

  const onSubmit = (event: FormEvent<HTMLFormElement>) => {
    const data = new FormData(event.currentTarget);
    setProject({ name: String(data.get('name')), slug: String(data.get('slug')) });
  };

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        padding: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      <div style={{ display: 'grid', minWidth: 0 }}>
        <Text size={3} fontWeight={500} style={{ overflowWrap: 'anywhere' }}>{project.name}</Text>
        <Text size={2} style={{ color: 'var(--label-secondary)', overflowWrap: 'anywhere' }}>vega.app/{project.slug}</Text>
      </div>
      <Dialog>
        <DialogTrigger asChild>
          <Button size='sm' variant='secondary' appearance='ghost'>Rename</Button>
        </DialogTrigger>
        <DialogContent aria-labelledby={titleId}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={titleId} as='h2' size={4} fontWeight={600}>Rename project</Heading>
            <DialogClose asChild>
              <IconButton
                aria-label='Close'
                variant='secondary'
                appearance='ghost'
                style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
              >
                <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
              </IconButton>
            </DialogClose>
          </div>
          <form method='dialog' onSubmit={onSubmit} style={{ display: 'grid', gap: 'var(--spacing-8)', marginBlockStart: 'var(--spacing-8)' }}>
            <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
              <Label htmlFor={nameId}>Project name</Label>
              <TextField>
                <TextFieldInput id={nameId} name='name' defaultValue={project.name} required maxLength={60} autoFocus />
              </TextField>
            </div>
            <div style={{ display: 'grid', gap: 'var(--spacing-3)' }}>
              <Label htmlFor={slugId}>URL</Label>
              <TextField>
                <TextFieldInput
                  id={slugId}
                  name='slug'
                  defaultValue={project.slug}
                  required
                  pattern='[a-z0-9-]+'
                  aria-describedby={slugHintId}
                />
              </TextField>
              <HelperText id={slugHintId}>Lowercase letters, digits and hyphens.</HelperText>
            </div>
            <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-4)' }}>
              <DialogClose asChild>
                <Button type='button' variant='secondary' appearance='ghost'>Cancel</Button>
              </DialogClose>
              <Button type='submit'>Save</Button>
            </div>
          </form>
        </DialogContent>
      </Dialog>
    </div>
  );
}
```

### Layout/Overlay

#### Layout/Overlay: server-side validation error

When the check happens after submit, use a regular form and close the dialog yourself: `onSubmit` prevents the default, waits for the check and either shows the error linked with `aria-describedby` and `aria-invalid` or sets `open` to `false`. Try submitting the prefilled address, which is already taken. Editing the field clears the error.

```tsx
import { useId, useState, type CSSProperties, type FormEvent } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  HelperText,
  Icon,
  IconButton,
  Label,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const takenEmails = ['ada@northwind.dev', 'grace@northwind.dev'];

const checkEmail = (email: string) => new Promise<boolean>((resolve) => {
  setTimeout(() => resolve(!takenEmails.includes(email.toLowerCase())), 600);
});

export default function ServerValidationError() {
  const titleId = useId();
  const emailId = useId();
  const errorId = useId();
  const [open, setOpen] = useState(false);
  const [email, setEmail] = useState('linus@northwind.dev');
  const [error, setError] = useState('');
  const [pending, setPending] = useState(false);

  const changeOpen = (next: boolean) => {
    setError('');
    setOpen(next);
  };

  const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const next = String(new FormData(event.currentTarget).get('email'));

    setPending(true);
    const available = await checkEmail(next);
    setPending(false);

    if (!available) {
      setError(`${next} already belongs to another account.`);
      return;
    }
    setEmail(next);
    setOpen(false);
  };

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        padding: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      <div style={{ display: 'grid', minWidth: 0 }}>
        <Text size={2} style={{ color: 'var(--label-secondary)' }}>Sign-in email</Text>
        <Text size={3} fontWeight={500} style={{ overflowWrap: 'anywhere' }}>{email}</Text>
      </div>
      <Dialog open={open} onChangeOpen={changeOpen}>
        <DialogTrigger asChild>
          <Button size='sm' variant='secondary' appearance='ghost'>Change</Button>
        </DialogTrigger>
        <DialogContent aria-labelledby={titleId}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={titleId} as='h2' size={4} fontWeight={600}>Change sign-in email</Heading>
            <DialogClose asChild>
              <IconButton
                aria-label='Close'
                variant='secondary'
                appearance='ghost'
                style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
              >
                <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
              </IconButton>
            </DialogClose>
          </div>
          <form onSubmit={onSubmit} style={{ display: 'grid', gap: 'var(--spacing-3)', marginBlockStart: 'var(--spacing-8)' }}>
            <Label htmlFor={emailId}>New email</Label>
            <TextField error={error !== ''}>
              <TextFieldInput
                id={emailId}
                name='email'
                type='email'
                defaultValue='grace@northwind.dev'
                required
                aria-invalid={error !== ''}
                aria-describedby={error ? errorId : undefined}
                onChange={() => setError('')}
              />
            </TextField>
            {error && <HelperText id={errorId} error>{error}</HelperText>}
            <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-9)' }}>
              <DialogClose asChild>
                <Button type='button' variant='secondary' appearance='ghost'>Cancel</Button>
              </DialogClose>
              <Button type='submit' disabled={pending} style={{ minWidth: '11ch' }}>{pending ? 'Checking…' : 'Save email'}</Button>
            </div>
          </form>
        </DialogContent>
      </Dialog>
    </div>
  );
}
```

### Error

#### Error: destructive action disabled until the name is typed

The Delete button stays disabled until the field matches the workspace name, so neither a click nor `Enter` in the field can confirm by accident. Cancel is not a submit button, so `Enter` never triggers it either.

```tsx
import { useId, useState, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Label,
  Text,
  TextField,
  TextFieldInput,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const workspace = 'northwind-studio';

const destructive = {
  '--button-background-color': 'var(--color-red-accent-500)',
  '--button-background-color-hover': 'var(--color-red-accent-600)',
  '--button-background-color-active': 'var(--color-red-accent-700)',
} as CSSProperties;

export default function TypeToConfirm() {
  const titleId = useId();
  const descriptionId = useId();
  const fieldId = useId();
  const [typed, setTyped] = useState('');

  const confirmed = typed === workspace;

  return (
    <Dialog onChangeOpen={() => setTyped('')}>
      <DialogTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Delete workspace</Button>
      </DialogTrigger>
      <DialogContent aria-labelledby={titleId} aria-describedby={descriptionId}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Heading id={titleId} as='h2' size={4} fontWeight={600}>Delete {workspace}?</Heading>
          <DialogClose asChild>
            <IconButton
              aria-label='Close'
              variant='secondary'
              appearance='ghost'
              style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
            >
              <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
            </IconButton>
          </DialogClose>
        </div>
        <Text id={descriptionId} size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
          This permanently deletes 14 projects, 312 files and the billing history. Members lose access immediately.
        </Text>
        <form method='dialog' style={{ display: 'grid', gap: 'var(--spacing-3)', marginBlockStart: 'var(--spacing-10)' }}>
          <Label htmlFor={fieldId}>Type <b>{workspace}</b> to confirm</Label>
          <TextField>
            <TextFieldInput
              id={fieldId}
              value={typed}
              autoComplete='off'
              spellCheck={false}
              onChange={(event) => setTyped(event.currentTarget.value)}
            />
          </TextField>
          <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-9)' }}>
            <DialogClose asChild>
              <Button type='button' variant='secondary' appearance='ghost'>Cancel</Button>
            </DialogClose>
            <Button type='submit' value='delete' disabled={!confirmed} style={confirmed ? destructive : undefined}>Delete workspace</Button>
          </div>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

### Disabled

#### Disabled: long scrollable content

Only the list scrolls: it gets a `max-height`, `overflow: auto` and `overscroll-behavior: contain`, while the heading and the Done button stay in place. The scrollable list is focusable and named, so keyboard users can scroll it too.

```tsx
import { useId, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const people = ['Ada Lovelace', 'Grace Hopper', 'Linus Torvalds', 'Margaret Hamilton', 'Alan Kay'];
const actions = ['changed the plan to Pro', 'invited a member', 'exported invoices', 'rotated the API key', 'updated billing details'];

const events = Array.from({ length: 40 }, (_, index) => {
  const minutes = 18 * 60 + 55 - index * 7;
  const time = `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`;

  return { id: index, time, text: `${people[index % people.length]} ${actions[(index * 3) % actions.length]}` };
});

export default function ScrollableContentDialog() {
  const titleId = useId();

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Activity log</Button>
      </DialogTrigger>
      <DialogContent aria-labelledby={titleId}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Heading id={titleId} as='h2' size={4} fontWeight={600}>Activity log</Heading>
          <DialogClose asChild>
            <IconButton
              aria-label='Close'
              variant='secondary'
              appearance='ghost'
              style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
            >
              <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
            </IconButton>
          </DialogClose>
        </div>
        <ol
          tabIndex={0}
          aria-label='Events, newest first'
          style={{
            display: 'grid',
            gap: 'var(--spacing-6)',
            maxHeight: '50vh',
            overflow: 'auto',
            overscrollBehavior: 'contain',
            margin: 'var(--spacing-8) 0 0',
            paddingBlock: 'var(--spacing-6)',
            paddingInline: 0,
            borderBlock: '1px solid var(--separator-opaque)',
            listStyle: 'none',
          }}
        >
          {events.map((event) => (
            <li key={event.id} style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--spacing-6)' }}>
              <Text size={2} style={{ flexShrink: 0, minWidth: '5ch', color: 'var(--label-secondary)', fontVariantNumeric: 'tabular-nums' }}>{event.time}</Text>
              <Text size={3}>{event.text}</Text>
            </li>
          ))}
        </ol>
        <form method='dialog' style={{ display: 'flex', justifyContent: 'flex-end', marginBlockStart: 'var(--spacing-12)' }}>
          <Button type='submit'>Done</Button>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

#### Disabled: dialog opened from a popover menu

A menu item closes the popover and opens a controlled dialog in the same handler. The menu item no longer exists when the dialog closes, so `onClose` moves focus back to the menu trigger.

```tsx
import { useId, useRef, useState, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  Heading,
  Icon,
  IconButton,
  Popover,
  PopoverContent,
  PopoverTrigger,
  Text,
} from '@vega-ui/react';
import { EllipsisIcon, XIcon } from '@vega-ui/icons';

export default function OpenedFromPopoverMenu() {
  const titleId = useId();
  const menuTrigger = useRef<HTMLButtonElement>(null);
  const [menuOpen, setMenuOpen] = useState(false);
  const [dialogOpen, setDialogOpen] = useState(false);
  const [archived, setArchived] = useState(false);

  const openArchiveDialog = () => {
    setMenuOpen(false);
    setDialogOpen(true);
  };

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        padding: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      <div style={{ display: 'grid', minWidth: 0 }}>
        <Text size={3} fontWeight={500}>Website redesign</Text>
        <Text size={2} style={{ color: 'var(--label-secondary)' }}>{archived ? 'Archived · read-only' : '12 tasks · updated today'}</Text>
      </div>
      <Popover placement='bottom-end' open={menuOpen} onChangeOpen={setMenuOpen}>
        <PopoverTrigger asChild ref={menuTrigger}>
          <IconButton aria-label='Project actions' variant='secondary' appearance='ghost'>
            <Icon><EllipsisIcon /></Icon>
          </IconButton>
        </PopoverTrigger>
        <PopoverContent style={{ padding: 'var(--spacing-2)' }}>
          <div style={{ display: 'grid', minWidth: 160 }}>
            <Button appearance='transparent' variant='secondary' style={{ justifyContent: 'flex-start' }} onClick={() => setMenuOpen(false)}>Duplicate</Button>
            <Button appearance='transparent' variant='secondary' style={{ justifyContent: 'flex-start' }} onClick={openArchiveDialog} disabled={archived}>Archive…</Button>
          </div>
        </PopoverContent>
      </Popover>

      <Dialog open={dialogOpen} onChangeOpen={setDialogOpen}>
        <DialogContent aria-labelledby={titleId} onClose={() => menuTrigger.current?.focus()}>
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
            <Heading id={titleId} as='h2' size={4} fontWeight={600}>Archive Website redesign?</Heading>
            <DialogClose asChild>
              <IconButton
                aria-label='Close'
                variant='secondary'
                appearance='ghost'
                style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
              >
                <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
              </IconButton>
            </DialogClose>
          </div>
          <Text size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
            Archived projects are read-only and hidden from the sidebar.
          </Text>
          <form method='dialog' style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
            <Button type='submit' value='cancel' variant='secondary' appearance='ghost'>Cancel</Button>
            <Button type='submit' value='archive' onClick={() => setArchived(true)}>Archive</Button>
          </form>
        </DialogContent>
      </Dialog>
    </div>
  );
}
```

### Edge

#### Edge: nested confirmation step

The member dialog opens a confirmation dialog from inside its content. `Escape` closes only the top one; confirming closes both, because the handler also sets the outer `open` to `false`.

```tsx
import { useId, useState, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const destructive = {
  '--button-background-color': 'var(--color-red-accent-500)',
  '--button-background-color-hover': 'var(--color-red-accent-600)',
  '--button-background-color-active': 'var(--color-red-accent-700)',
} as CSSProperties;

export default function NestedConfirmStep() {
  const memberTitleId = useId();
  const confirmTitleId = useId();
  const [memberOpen, setMemberOpen] = useState(false);
  const [removed, setRemoved] = useState(false);

  const remove = () => {
    setRemoved(true);
    setMemberOpen(false);
  };

  return (
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'space-between',
        gap: 'var(--spacing-6)',
        boxSizing: 'border-box',
        width: '100%',
        padding: 'var(--spacing-8)',
        background: 'var(--surface-primary)',
        border: '1px solid var(--separator-opaque)',
        borderRadius: 'var(--radius-5)',
      }}
    >
      <div style={{ display: 'grid', minWidth: 0 }}>
        <Text size={3} fontWeight={500}>Grace Hopper</Text>
        <Text size={2} role='status' style={{ color: 'var(--label-secondary)' }}>
          {removed ? 'Removed from the team' : 'Admin · grace@northwind.dev'}
        </Text>
      </div>
      <div style={{ display: 'flex', gap: 'var(--spacing-4)' }}>
        {removed && <Button size='sm' variant='secondary' appearance='ghost' onClick={() => setRemoved(false)}>Undo</Button>}
        <Dialog open={memberOpen} onChangeOpen={setMemberOpen}>
          <DialogTrigger asChild>
            <Button size='sm' variant='secondary' appearance='ghost'>Manage</Button>
          </DialogTrigger>
          <DialogContent aria-labelledby={memberTitleId}>
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
              <Heading id={memberTitleId} as='h2' size={4} fontWeight={600}>Grace Hopper</Heading>
              <DialogClose asChild>
                <IconButton
                  aria-label='Close'
                  variant='secondary'
                  appearance='ghost'
                  style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
                >
                  <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
                </IconButton>
              </DialogClose>
            </div>
            <Text size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
              grace@northwind.dev · Admin since March 2024
            </Text>
            <div style={{ display: 'flex', justifyContent: 'space-between', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
              <Dialog>
                <DialogTrigger asChild>
                  <Button variant='secondary' appearance='ghost'>Remove from team…</Button>
                </DialogTrigger>
                <DialogContent aria-labelledby={confirmTitleId}>
                  <Heading id={confirmTitleId} as='h2' size={4} fontWeight={600}>Remove Grace Hopper?</Heading>
                  <Text size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
                    She loses access to all team projects. Her files stay in the workspace.
                  </Text>
                  <form method='dialog' style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
                    <Button type='submit' value='cancel' variant='secondary' appearance='ghost'>Keep</Button>
                    <Button type='submit' value='remove' style={destructive} onClick={remove}>Remove</Button>
                  </form>
                </DialogContent>
              </Dialog>
              <DialogClose asChild>
                <Button>Done</Button>
              </DialogClose>
            </div>
          </DialogContent>
        </Dialog>
      </div>
    </div>
  );
}
```

#### Edge: dialog that closes only through its actions

With `closedBy='none'` neither `Escape` nor a backdrop click closes the dialog, so there is no close button either; the only way out is one of the two answers. `autoFocus` puts focus on the safe answer, and the chosen `returnValue` is shown below the trigger.

```tsx
import { useId, useState } from 'react';
import {
  Button,
  Dialog,
  DialogContent,
  DialogTrigger,
  Heading,
  Text,
} from '@vega-ui/react';

export default function BlockingDialog() {
  const titleId = useId();
  const descriptionId = useId();
  const [choice, setChoice] = useState('');

  return (
    <div style={{ display: 'grid', gap: 'var(--spacing-6)', justifyItems: 'center', width: '100%' }}>
      <Dialog>
        <DialogTrigger asChild>
          <Button variant='secondary' appearance='ghost'>Simulate session timeout</Button>
        </DialogTrigger>
        <DialogContent
          closedBy='none'
          aria-labelledby={titleId}
          aria-describedby={descriptionId}
          onClose={(event) => setChoice(event.currentTarget.returnValue)}
        >
          <Heading id={titleId} as='h2' size={4} fontWeight={600}>Your session is about to expire</Heading>
          <Text id={descriptionId} size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
            You have been inactive for 25 minutes. Unsaved changes in the editor will be lost when you sign out.
          </Text>
          <form method='dialog' style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
            <Button type='submit' value='sign-out' variant='secondary' appearance='ghost'>Sign out</Button>
            <Button type='submit' value='stay' autoFocus>Stay signed in</Button>
          </form>
        </DialogContent>
      </Dialog>
      <Text size={2} style={{ color: 'var(--label-secondary)', minHeight: '1lh' }}>{choice ? `returnValue: ${choice}` : ''}</Text>
    </div>
  );
}
```

## Anatomy

### Overview

`Dialog` is a [compound component](https://vega-ui.dev/docs/glossary/#compound-component). The root owns open state, focus management, dismissal behavior, and modal coordination across the exported child parts.

### Required Parts

#### `Dialog`

Root provider for the dialog state.

- Required: yes
- Owns: open state, dismissal, focus lifecycle
- Must wrap every other `Dialog*` part

#### `DialogTrigger`

User action that opens the dialog.

- Required: effectively yes for normal product flows
- Typical usage: `asChild` with `Button`
- Should stay inside `Dialog`

#### `DialogContent`

Primary modal surface.

- Required: yes
- Owns: the native modal `<dialog>`, its `::backdrop`, and open and close transitions
- Renders into `document.body`, so no portal part is needed

### Optional Parts

#### `DialogClose`

Closes the dialog when clicked.

- Required: no
- Renders a `<button type="button">` with no styles, icon, or label; with `asChild` it renders your own button instead
- Calls `onChangeOpen(false)` before your `onClick`, so the close transition plays and a controlled parent can keep the dialog open
- Give an icon-only close button an `aria-label`

### Your Own Markup

The library does not ship header or title parts. They carry no behavior that plain markup cannot express, so you compose them yourself.

#### Heading

Names the dialog.

- Required: a name is required, either a heading referenced by `aria-labelledby` on `DialogContent` or `aria-label` on `DialogContent`
- Typical usage: `Heading` with an `id`

#### Action buttons

Buttons that answer the dialog, such as Cancel and Confirm.

- Put them in a `<form method="dialog">`; submitting it closes the dialog through `onChangeOpen(false)`, and the submit button's `value` becomes the dialog's `returnValue`
- Use `DialogClose` for a standalone close button, such as the X in a header

### Composition Order

Typical composition:

1. `Dialog`
2. `DialogTrigger`
3. `DialogContent`
4. Your heading, an optional `DialogClose`, body, and a `<form method="dialog">` with action buttons

### Valid Composition Patterns

```tsx
import { useId, type CSSProperties } from 'react';
import {
  Button,
  Dialog,
  DialogClose,
  DialogContent,
  DialogTrigger,
  Heading,
  Icon,
  IconButton,
  Text,
} from '@vega-ui/react';
import { XIcon } from '@vega-ui/icons';

const destructive = {
  '--button-background-color': 'var(--color-red-accent-500)',
  '--button-background-color-hover': 'var(--color-red-accent-600)',
  '--button-background-color-active': 'var(--color-red-accent-700)',
} as CSSProperties;

export default function MinimalComposition() {
  const titleId = useId();

  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant='secondary' appearance='ghost'>Leave workspace</Button>
      </DialogTrigger>
      <DialogContent aria-labelledby={titleId}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--spacing-8)' }}>
          <Heading id={titleId} as='h2' size={4} fontWeight={600}>Leave Northwind Studio?</Heading>
          <DialogClose asChild>
            <IconButton
              aria-label='Close'
              variant='secondary'
              appearance='ghost'
              style={{ flexShrink: 0, '--icon-button-size': '24px', '--icon-button-br-ratio': 0.5 } as CSSProperties}
            >
              <Icon size='xs'><XIcon strokeWidth={2.5} /></Icon>
            </IconButton>
          </DialogClose>
        </div>
        <Text size={3} style={{ display: 'block', marginBlockStart: 'var(--spacing-4)', color: 'var(--label-secondary)' }}>
          You will lose access to 14 projects until an admin invites you again.
        </Text>
        <form method='dialog' style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--spacing-4)', marginBlockStart: 'var(--spacing-12)' }}>
          <Button type='submit' value='cancel' variant='secondary' appearance='ghost'>Stay</Button>
          <Button type='submit' value='leave' style={destructive}>Leave workspace</Button>
        </form>
      </DialogContent>
    </Dialog>
  );
}
```

This is the production-safe default composition for modal flows.

### Invalid Composition Patterns

#### `DialogContent` outside `Dialog`

The child parts depend on root context. Rendering them outside `Dialog` breaks behavior.

#### Content without a name

`DialogContent` does not name itself. Without `aria-labelledby` pointing to your heading or an `aria-label`, screen readers announce an unnamed dialog.

#### Nested dialogs without retesting focus flow

Nested overlay stacks can still be valid, but they must be treated as an integration edge case, not assumed to work from isolated component testing alone.
