# Dialog

Package: @commenda/pathfinder@0.1.0
Page: /docs/components/dialog
Figma: https://www.figma.com/design/dn7BkGYEfIyP0o2zfesfVh/The-System?node-id=372-2

```tsx
import { useState } from 'react';

import { Button } from '@commenda/pathfinder/button';
import { Dialog } from '@commenda/pathfinder/dialog';
import { Input } from '@commenda/pathfinder/input';

export const DialogDefault = () => {
  const [open, setOpen] = useState(false);
  const [name, setName] = useState('Acme Inc.');

  return (
    <Dialog
      title="Edit entity details"
      description="Changes apply to filings created from today."
      trigger={<Button variant="secondary">Edit entity</Button>}
      open={open}
      onOpenChange={setOpen}
      footerConfig={{
        primaryAction: {
          label: 'Save changes',
          type: 'submit',
          form: 'entity-dialog-form',
        },
      }}
    >
      <form
        id="entity-dialog-form"
        onSubmit={(event) => {
          event.preventDefault();
          setOpen(false);
        }}
      >
        <Input label="Entity name" value={name} onValueChange={setName} />
      </form>
    </Dialog>
  );
};
```


Import `Dialog` and `ConfirmDialog` from `@commenda/pathfinder/dialog`.

## API reference [#api-reference]

| Prop | Type | Requirement | Behavior |
| --- | --- | --- | --- |
| `title` | `string` | required | Required accessible heading |
| `description` | `ReactNode` | optional | Optional supporting text |
| `trigger` | `ReactElement<any, string \| React.JSXElementConstructor<any>>` | optional | Optional ref-forwarding button |
| `children` | `ReactNode` | required | Form or focused task body |
| `size` | `"sm" \| "md" \| "lg" \| "xl"` | optional | lg; 400 / 480 / 600 / 800px, viewport-clamped |
| `footerConfig` | `OverlayFooterConfig` | optional | Optional footer |
| `dismissConfig` | `OverlayDismissConfig` | optional | Outside dismissal enabled; guard may be async |
| `focusConfig` | `OverlayFocusConfig` | optional | First body control, then popup; returns to trigger |
| `className` | `string` | optional | Popup classes |
| `open` | `boolean` | conditional |  |
| `onOpenChange` | `((open: boolean) => void)` | conditional |  |
| `defaultOpen` | `boolean` | optional |  |

### footerConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `primaryAction` | `OverlayAction` | No |  |
| `secondaryAction` | `OverlayAction` | No |  |
| `leadingContent` | `ReactNode` | No |  |
| `cancelLabel` | `string` | No |  |

### dismissConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `outsidePress` | `boolean` | No |  |
| `beforeClose` | `((reason: OverlayCloseReason) => boolean \| Promise<boolean>)` | No |  |
| `onError` | `((error: unknown) => void)` | No |  |

### focusConfig fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `initialFocusRef` | `RefObject<HTMLElement \| null>` | No |  |
| `returnFocusRef` | `RefObject<HTMLElement \| null>` | No |  |

`open` with `onOpenChange` is controlled; otherwise use `defaultOpen`. Primary actions never imply success or automatically close the surface. Close it after the application completes its work.

`footerConfig` accepts `primaryAction`, `secondaryAction`, `leadingContent`, and `cancelLabel`. Actions have `label`, `onClick`, native `type`/`form`, `disabled`, `disabledReason`, `loadingConfig`, `destructive`, and icons. Without a secondary action the footer renders Cancel. A footer is absent unless configured. Submit uses native form IDs; IDs must be unique.

## Sizes [#sizes]


```tsx
import { Button } from '@commenda/pathfinder/button';
import { Dialog } from '@commenda/pathfinder/dialog';

export const DialogSizes = () => (
  <div className="pf:flex pf:flex-wrap pf:gap-md">
    {(['sm', 'md', 'lg', 'xl'] as const).map((size) => (
      <Dialog
        key={size}
        size={size}
        title={`Dialog ${size}`}
        trigger={<Button variant="secondary">{size}</Button>}
        footerConfig={{}}
      >
        <p className="pf:m-0">
          Choose the smallest size that comfortably fits the task.
        </p>
      </Dialog>
    ))}
  </div>
);
```


## Confirmation [#confirmation]


```tsx
import { useState } from 'react';

import { Button } from '@commenda/pathfinder/button';
import { ConfirmDialog } from '@commenda/pathfinder/dialog';

export const DialogConfirmation = () => {
  const [open, setOpen] = useState(false);
  const [deleted, setDeleted] = useState(false);

  return (
    <>
      <ConfirmDialog
        title="Delete this policy?"
        description="Contract R&D services and its 3 versions will be removed. This can’t be undone."
        trigger={
          <Button variant="secondary" destructive>
            Delete policy
          </Button>
        }
        open={open}
        onOpenChange={setOpen}
        confirmAction={{
          label: 'Delete policy',
          destructive: true,
          onClick: () => {
            setDeleted(true);
            setOpen(false);
          },
        }}
      />
      <output>{deleted ? 'Policy deleted.' : ''}</output>
    </>
  );
};
```


`ConfirmDialog` requires `title`, `description`, and `confirmAction`. It also accepts the same open state, trigger, focus config, a `cancelLabel`, and dismissal guard. It uses alert-dialog semantics, focuses Cancel, has no close icon, and ignores outside presses. A loading confirmation blocks dismissal until the application completes or reports an error.

## Scrolling and nested controls [#scrolling-and-nested-controls]


```tsx
import { Button } from '@commenda/pathfinder/button';
import { Dialog } from '@commenda/pathfinder/dialog';
import { Select } from '@commenda/pathfinder/select';

export const DialogScrolling = () => (
  <Dialog
    title="Review filings"
    description="The body scrolls independently of actions."
    trigger={<Button variant="secondary">Review filings</Button>}
    footerConfig={{
      primaryAction: { label: 'Save review', onClick: () => {} },
    }}
  >
    <Select
      label="Jurisdiction"
      options={[
        { value: 'US', label: 'United States' },
        { value: 'GB', label: 'United Kingdom' },
      ]}
    />
    {Array.from({ length: 30 }, (_, index) => (
      <p key={index}>
        Filing {index + 1}: Review the registration details and supporting
        documents.
      </p>
    ))}
  </Dialog>
);
```


Body scrolling adds header/footer hairlines. Select menus and popovers remain inside the focus boundary through their portals. Escape closes the innermost surface first.

`beforeClose(reason)` receives `escape`, `outside`, `close`, `cancel`, or `trigger` and returns a boolean or promise. False keeps the dialog open; failure announces an error. Duplicate requests are ignored while pending; a decision from an earlier open cycle cannot close a reopened dialog. Setting controlled `open=false` is an explicit application decision and bypasses the guard. Read the [overlay rules](/docs/components/dialog#design-rules).

## Design rules [#design-rules]

Figma guidance and reviewed implementation decisions for this component. Rule IDs remain stable for source comments and AI references.

### Dialog and confirmation [#dialog-and-confirmation]

- **DIALOG-001:** One focused task. sm/md/lg/xl are 400/480/600/800px; default lg. Radius 12, 0.5px pearl-300 edge, modal shadow. Backdrop pearl-1000 at 10%, no blur. Full-screen and multi-step work belongs on a page.
- **DIALOG-002:** Heading/15 title, Copy/13 description. Header padding 20px top, 24px start, 16px end/bottom; text inset 4px. Body 24px horizontal, 4px top, 16px bottom. Footer 24px horizontal, 16px top, 20px bottom. Body scrolls independently; hairlines appear after scrolling. Cancel then action-named primary at 32px; optional leading content.
- **DIALOG-003:** Initial focus enters the body, focus remains contained, and returns to the trigger. Explicit refs override defaults. Escape affects the innermost surface. Async guards block duplicate requests and ignore stale cycles; callers own mutation success and controlled closure.
- **CONFIRM-001:** Alertdialog with question and consequence. Focus Cancel; no close icon or outside dismissal. Destructive action is named, not “Yes”.
- **CONFIRM-002:** Clicking is not success. Loading and failure remain app-owned; a loading confirmation cannot be dismissed by its controls.

### Motion and edge cases [#motion-and-edge-cases]

Occasional overlays use motion for spatial consistency. Motion paths and timings follow Kakashi: Dialog fades and moves from 95% scale with a 2% vertical offset in 200ms both ways; Popover fades from 95% scale and an 8px offset toward its trigger in 150ms both ways. Popover direction follows the resolved placement after collision handling. Right Sheet slides in for 500ms and out for 300ms; bottom Sheet slides for 500ms both ways. Sheet panels do not fade during normal motion, and modal backdrops follow the panel duration.

Pathfinder retains its foundation ease-out curve for Dialog/Popover and drawer curve `(0.32,0.72,0,1)` for Sheets. These are deliberate curve choices; this is not a copy of Kakashi’s legacy Radix keyframe implementation. Base UI continues to own presence, focus and dismissal. Transitions use a single `transform` value plus opacity where relevant, so Tailwind v4’s separate scale/translate properties cannot bypass interpolation. Closing or reopening mid-transition reverses from the current rendered position.

Keyboard opening and dismissal remain immediate, including Close and Cancel activation. Reduced motion removes scale and translation: Dialog/Popover keep their fade, while Sheets change position immediately and their modal backdrop can fade. Drag writes only the popup transform, never layout or inherited animation variables. Programmatic openings animate by default; explicit keyboard interactions suppress motion.
