Skip to content
Ndop UI

Dialog

A window overlaid on the primary window, rendering the content underneath inert.

import { Component, signal } from '@angular/core';
import { form, FormField, required, submit } from '@angular/forms/signals';
import { NdIcon } from '@ndop/core';
import { Button } from '@/ui/button/button';
import {
  DialogCloseButton,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogOverlay,
  DialogTitle,
  Dialog,
  DialogClose,
  DialogPortal,
  DialogTrigger,
} from '@/ui/dialog/dialog';
import { Input } from '@/ui/input/input';
import { Label } from '@/ui/label/label';

interface Profile {
  name: string;
  username: string;
}

/**
 * An edit-profile dialog with a Signal Forms form. Saving validates the
 * form, closes the dialog when it passes, and keeps it open otherwise.
 */
@Component({
  selector: 'example-dialog-demo',
  imports: [
    Dialog,
    DialogTrigger,
    DialogPortal,
    DialogClose,
    DialogOverlay,
    DialogContent,
    DialogHeader,
    DialogFooter,
    DialogTitle,
    DialogDescription,
    DialogCloseButton,
    NdIcon,
    Button,
    Input,
    Label,
    FormField,
  ],
  template: `
    <ng-container ndDialog [(open)]="open">
      <button ndButton ndDialogTrigger variant="outline">Edit profile</button>
      <ng-template ndDialogPortal>
        <div ndDialogOverlay></div>
        <div ndDialogContent class="sm:max-w-[425px]">
          <form class="grid gap-4" (submit)="save($event)">
            <div ndDialogHeader>
              <h2 ndDialogTitle>Edit profile</h2>
              <p ndDialogDescription>
                Make changes to your profile here. Click save when you're done.
              </p>
            </div>
            <div class="grid gap-4">
              <div class="grid gap-3">
                <label ndLabel for="dialog-demo-name">Name</label>
                <input
                  ndInput
                  id="dialog-demo-name"
                  [formField]="profile.name"
                />
              </div>
              <div class="grid gap-3">
                <label ndLabel for="dialog-demo-username">Username</label>
                <input
                  ndInput
                  id="dialog-demo-username"
                  [formField]="profile.username"
                />
              </div>
            </div>
            <div ndDialogFooter>
              <button ndButton ndDialogClose type="button" variant="outline">
                Cancel
              </button>
              <button ndButton type="submit">Save changes</button>
            </div>
          </form>
          <button ndDialogCloseButton>
            <svg ndIcon="x"></svg>
            <span class="sr-only">Close</span>
          </button>
        </div>
      </ng-template>
    </ng-container>
  `,
})
export class DialogDemoExample {
  protected readonly open = signal(false);

  private readonly model = signal<Profile>({
    name: 'Ada Lovelace',
    username: '@ada',
  });

  protected readonly profile = form(this.model, (path) => {
    required(path.name);
    required(path.username);
  });

  /**
   * Validates and saves the profile, then closes the dialog.
   *
   * @param event - The form's submit event.
   */
  protected async save(event: Event): Promise<void> {
    event.preventDefault();
    await submit(this.profile, async () => {
      this.open.set(false);
      return undefined;
    });
  }
}

A dialog interrupts the page to ask for a decision or information, and blocks the rest of the page until it closes. It follows the WAI-ARIA modal dialog pattern: focus moves into the dialog and stays there, Escape and the close button close it, and focus returns to the trigger. Its content renders in an ng-template portal, so nothing is in the page until it opens.

Installation

npx @ndop/cli add dialog

Usage

import { Button } from '@/ui/button/button';
import {
  DialogCloseButton,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogOverlay,
  DialogTitle,
  Dialog,
  DialogClose,
  DialogPortal,
  DialogTrigger,
} from '@/ui/dialog/dialog';
import { Input } from '@/ui/input/input';
import { Label } from '@/ui/label/label';
<ng-container ndDialog [(open)]="open">
  <button ndButton ndDialogTrigger variant="outline">Edit profile</button>
  <ng-template ndDialogPortal>
    <div ndDialogOverlay></div>
    <div ndDialogContent class="sm:max-w-[425px]">
      <form class="grid gap-4" (submit)="save($event)">
        <div ndDialogHeader>
          <h2 ndDialogTitle>Edit profile</h2>
          <p ndDialogDescription>
            Make changes to your profile here. Click save when you're done.
          </p>
        </div>
        <div class="grid gap-4">
          <div class="grid gap-3">
            <label ndLabel for="dialog-demo-name">Name</label>
            <input
              ndInput
              id="dialog-demo-name"
              [formField]="profile.name"
            />
          </div>
          <div class="grid gap-3">
            <label ndLabel for="dialog-demo-username">Username</label>
            <input
              ndInput
              id="dialog-demo-username"
              [formField]="profile.username"
            />
          </div>
        </div>
        <div ndDialogFooter>
          <button ndButton ndDialogClose type="button" variant="outline">
            Cancel
          </button>
          <button ndButton type="submit">Save changes</button>
        </div>
      </form>
      <button ndDialogCloseButton>
        <svg ndIcon="x"></svg>
        <span class="sr-only">Close</span>
      </button>
    </div>
  </ng-template>
</ng-container>

Examples

Dialog close button

A share dialog with a read-only link and a footer close button.

import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import { Button } from '@/ui/button/button';
import {
  DialogCloseButton,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogOverlay,
  DialogTitle,
  Dialog,
  DialogClose,
  DialogPortal,
  DialogTrigger,
} from '@/ui/dialog/dialog';
import { Input } from '@/ui/input/input';
import { Label } from '@/ui/label/label';

/** A share dialog with a read-only link and a footer close button. */
@Component({
  selector: 'example-dialog-close-button',
  imports: [
    Dialog,
    DialogTrigger,
    DialogPortal,
    DialogClose,
    DialogOverlay,
    DialogContent,
    DialogHeader,
    DialogFooter,
    DialogTitle,
    DialogDescription,
    DialogCloseButton,
    NdIcon,
    Button,
    Input,
    Label,
  ],
  template: `
    <ng-container ndDialog>
      <button ndButton ndDialogTrigger variant="outline">Share</button>
      <ng-template ndDialogPortal>
        <div ndDialogOverlay></div>
        <div ndDialogContent class="sm:max-w-md">
          <div ndDialogHeader>
            <h2 ndDialogTitle>Share link</h2>
            <p ndDialogDescription>
              Anyone who has this link will be able to view this.
            </p>
          </div>
          <div class="grid gap-2">
            <label ndLabel for="dialog-close-button-link" class="sr-only">
              Link
            </label>
            <input
              ndInput
              id="dialog-close-button-link"
              value="https://ndop.dev/docs/installation"
              readonly
            />
          </div>
          <div ndDialogFooter class="sm:justify-start">
            <button ndButton ndDialogClose variant="secondary">Close</button>
          </div>
          <button ndDialogCloseButton>
            <svg ndIcon="x"></svg>
            <span class="sr-only">Close</span>
          </button>
        </div>
      </ng-template>
    </ng-container>
  `,
})
export class DialogCloseButtonExample {}

Dialog unsaved changes

A dialog that only closes through its buttons, so a click outside doesn't lose the user's changes.

import { Component } from '@angular/core';
import { Button } from '@/ui/button/button';
import {
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogOverlay,
  DialogTitle,
  Dialog,
  DialogClose,
  DialogPortal,
  DialogTrigger,
} from '@/ui/dialog/dialog';
import { Label } from '@/ui/label/label';
import { Textarea } from '@/ui/textarea/textarea';

/**
 * A dialog that a click outside doesn't close, so the user doesn't lose
 * what they typed by accident. Escape and the buttons still close it.
 */
@Component({
  selector: 'example-dialog-unsaved-changes',
  imports: [
    Button,
    DialogContent,
    DialogDescription,
    DialogFooter,
    DialogHeader,
    DialogOverlay,
    DialogTitle,
    Label,
    Dialog,
    DialogClose,
    DialogPortal,
    DialogTrigger,
    Textarea,
  ],
  template: `
    <ng-container ndDialog [closeOnOverlayClick]="false">
      <button ndButton ndDialogTrigger variant="outline">Write a note</button>
      <ng-template ndDialogPortal>
        <div ndDialogOverlay></div>
        <div ndDialogContent class="sm:max-w-[425px]">
          <div ndDialogHeader>
            <h2 ndDialogTitle>New note</h2>
            <p ndDialogDescription>
              The note is kept until you save or discard it.
            </p>
          </div>
          <div class="grid gap-2">
            <label ndLabel for="dialog-unsaved-changes-note">Note</label>
            <textarea ndTextarea id="dialog-unsaved-changes-note"></textarea>
          </div>
          <div ndDialogFooter>
            <button ndButton ndDialogClose variant="outline">Discard</button>
            <button ndButton ndDialogClose>Save note</button>
          </div>
        </div>
      </ng-template>
    </ng-container>
  `,
})
export class DialogUnsavedChangesExample {}

Accessibility

Implements the Dialog (Modal) pattern of the WAI-ARIA Authoring Practices.

Keyboard

Keys the component handles.

Key Action
TabMoves focus to the next focusable element inside the dialog; from the last one, wraps to the first.
ShiftTabMoves focus to the previous focusable element inside the dialog; from the first one, wraps to the last.
EscapeCloses the dialog, unless closeOnEscape is false.

Focus

  • On open: focus moves to the first focusable element in the content, or to the content element itself when it has none.
  • While open: focus is trapped in the content.
  • On close: focus returns to the element that had it before opening, usually the trigger.

Right-to-left

Nothing mirrors: the dialog is centered, and Tab order follows the DOM.

API reference

Dialog

[ndDialog]

A modal dialog (@ndop/primitives/dialog): it renders its portal's content over the page while open, traps focus inside it and returns it on close, and closes on Escape or a backdrop click. Put it on an ng-container; open is two-way bindable.

Dialog binding Type Default
[defaultOpen]
Initial open state when open is not bound.
booleanfalse
[closeOnEscape]
Whether Escape closes the dialog.
booleantrue
[closeOnOverlayClick]
Whether a click on [ndDialogOverlay] closes the dialog.
booleantrue
[(open)]
Whether the dialog is open. Two-way bindable with [(open)].
booleanfalse

DialogTrigger

[ndDialogTrigger]

The button that opens its dialog, with aria-haspopup, aria-expanded and aria-controls.

Manages aria-haspopup, aria-expanded, aria-controls.

Data attribute Values
data-slotdialog-trigger
data-state —

DialogPortal

ng-template[ndDialogPortal]

Marks the ng-template its dialog renders while open.

DialogClose

[ndDialogClose]

A button that closes its dialog.

Data attribute Values
data-slotdialog-close

DialogOverlay

[ndDialogOverlay]

Backdrop behind the dialog; a click on it closes the dialog.

Manages aria-hidden.

DialogOverlay binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotdialog-overlay
data-state —

DialogContent

[ndDialogContent]role dialog

The dialog box, centered on screen.

Manages aria-modal, aria-labelledby, aria-describedby.

DialogContent binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotdialog-content
data-state —

DialogCloseButton

button[ndDialogCloseButton]

The icon button in the content's corner that closes the dialog. Put an <svg ndIcon="x"> and a sr-only label inside it.

DialogCloseButton binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotdialog-close

DialogHeader

[ndDialogHeader]

Layout for the title and description.

DialogHeader binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotdialog-header

DialogFooter

[ndDialogFooter]

Layout for the action buttons.

DialogFooter binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotdialog-footer

DialogTitle

[ndDialogTitle]

The dialog's title; it names the dialog.

DialogTitle binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotdialog-title

DialogDescription

[ndDialogDescription]

The dialog's description; it describes the dialog.

DialogDescription binding Type Default
[class]
Extra classes, merged last so they win.
string''
Data attribute Values
data-slotdialog-description

Agent notes

Do

  • Give every dialog an ndDialogTitle; it names the dialog for screen readers.
  • Add an ndDialogDescription when the title alone doesn't explain what is being asked.
  • Keep one task per dialog, with the main action in the footer.
  • Bind [(open)] when the dialog's state matters to the page, for example to close it after a successful save.

Don't

  • Don't open a dialog from another dialog; replace its content or use a page instead.
  • Don't put long or scrolling forms in a dialog; send people to a page.
  • Don't use a dialog for messages that don't need an answer; show them inline.