<bpdm/ui />
Overlay

Dialog

A modal dialog — focus-trapped, scroll-locked, ESC/outside-click to close, in React (@bpdm/ui) and Angular (@bpdm/ng).

The Dialog is a modal built on Radix — focus trap, scroll lock, Esc and outside-click to close, and full ARIA are handled for you. Use the low-config Dialog (pass a trigger, title, body and footer), or compose the primitives (DialogRoot, DialogContent, …) for full control. In Angular it's <bpdm-dialog> with [bpdmDialogTrigger] / [bpdmDialogBody] / [bpdmDialogFooter].

Usage

dialog-usage.tsx
import { Dialog, DialogClose } from '@bpdm/ui/dialog';
import { Button } from '@bpdm/ui/button';

export function EditProfile() {
  return (
    <Dialog
      trigger={<Button>Edit profile</Button>}
      title="Edit profile"
      description="Update your details. Changes are saved when you click Save."
      footer={
        <>
          <DialogClose asChild>
            <Button variant="secondary" appearance="ghost">Cancel</Button>
          </DialogClose>
          <DialogClose asChild>
            <Button>Save changes</Button>
          </DialogClose>
        </>
      }
    >
      <p>Your profile is visible to everyone in the workspace.</p>
    </Dialog>
  );
}
dialog-usage.ts
import { Component } from '@angular/core';
import { BpdmDialog, BpdmDialogTrigger, BpdmDialogBody, BpdmDialogFooter, BpdmDialogClose } from '@bpdm/ng';
import { BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'edit-profile',
  imports: [BpdmDialog, BpdmDialogTrigger, BpdmDialogBody, BpdmDialogFooter, BpdmDialogClose, BpdmButton],
  templateUrl: './dialog-usage.html',
})
export class EditProfile {}
dialog-usage.html
<bpdm-dialog title="Edit profile" description="Update your details.">
  <button bpdmButton bpdmDialogTrigger>Edit profile</button>
  <ng-template bpdmDialogBody>
    <p>Your profile is visible to everyone in the workspace.</p>
  </ng-template>
  <ng-template bpdmDialogFooter>
    <button bpdmButton variant="secondary" appearance="ghost" bpdmDialogClose>Cancel</button>
    <button bpdmButton bpdmDialogClose>Save changes</button>
  </ng-template>
</bpdm-dialog>

Sizes

The size prop sets the panel width: sm, md (default), lg, and xl.

dialog-sizes.tsx
import { Dialog } from '@bpdm/ui/dialog';
import { Button } from '@bpdm/ui/button';

export function Sizes() {
  return (
    <>
      <Dialog size="sm" trigger={<Button>SM</Button>} title="SM dialog" />
      <Dialog size="md" trigger={<Button>MD</Button>} title="MD dialog" />
      <Dialog size="lg" trigger={<Button>LG</Button>} title="LG dialog" />
      <Dialog size="xl" trigger={<Button>XL</Button>} title="XL dialog" />
    </>
  );
}
dialog-sizes.ts
import { Component } from '@angular/core';
import { BpdmDialog, BpdmDialogTrigger, BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'dialog-sizes',
  imports: [BpdmDialog, BpdmDialogTrigger, BpdmButton],
  templateUrl: './dialog-sizes.html',
})
export class DialogSizes {}
dialog-sizes.html
<bpdm-dialog size="sm" title="SM dialog"><button bpdmButton bpdmDialogTrigger>SM</button></bpdm-dialog>
<bpdm-dialog size="md" title="MD dialog"><button bpdmButton bpdmDialogTrigger>MD</button></bpdm-dialog>
<bpdm-dialog size="lg" title="LG dialog"><button bpdmButton bpdmDialogTrigger>LG</button></bpdm-dialog>
<bpdm-dialog size="xl" title="XL dialog"><button bpdmButton bpdmDialogTrigger>XL</button></bpdm-dialog>

Controlled

Drive the open state yourself with open + onOpenChange (React) or two-way [(open)] (Angular) — handy for opening from a menu, a keyboard shortcut, or after an async step.

dialog-controlled.tsx
import { useState } from 'react';
import { Dialog } from '@bpdm/ui/dialog';
import { Button } from '@bpdm/ui/button';

export function Controlled() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button onClick={() => setOpen(true)}>Open from outside</Button>
      <Dialog
        open={open}
        onOpenChange={setOpen}
        title="Controlled dialog"
        description="Its open state is driven by your own React state."
        footer={<Button onClick={() => setOpen(false)}>Close</Button>}
      >
        <p>Open it from anywhere.</p>
      </Dialog>
    </>
  );
}
dialog-controlled.ts
import { Component, signal } from '@angular/core';
import { BpdmDialog, BpdmDialogFooter, BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'controlled',
  imports: [BpdmDialog, BpdmDialogFooter, BpdmButton],
  templateUrl: './controlled.html',
})
export class Controlled {
  readonly open = signal(false);
}
dialog-controlled.html
<button bpdmButton (click)="open.set(true)">Open from outside</button>
<bpdm-dialog [(open)]="open" title="Controlled dialog" description="Driven by your own state.">
  <ng-template bpdmDialogFooter>
    <button bpdmButton (click)="open.set(false)">Close</button>
  </ng-template>
</bpdm-dialog>

Scrollable content

Long bodies scroll inside the panel while the header and footer stay pinned.

dialog-scrollable.tsx
import { Dialog, DialogClose } from '@bpdm/ui/dialog';
import { Button } from '@bpdm/ui/button';

const sections = Array.from({ length: 12 }, (_, i) => `Section ${i + 1}`);

export function Terms() {
  return (
    <Dialog
      trigger={<Button>Terms of service</Button>}
      title="Terms of service"
      description="Please review before continuing."
      footer={
        <DialogClose asChild>
          <Button>I agree</Button>
        </DialogClose>
      }
    >
      {/* a tall block of content — it scrolls; the header/footer stay pinned */}
      <div className="space-y-3">
        {sections.map((s) => (
          <p key={s}>{s}: the full text of this clause goes here, and the panel scrolls when it overflows.</p>
        ))}
      </div>
    </Dialog>
  );
}
terms.ts
import { Component } from '@angular/core';
import { BpdmDialog, BpdmDialogTrigger, BpdmDialogBody, BpdmDialogFooter, BpdmDialogClose, BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'terms',
  imports: [BpdmDialog, BpdmDialogTrigger, BpdmDialogBody, BpdmDialogFooter, BpdmDialogClose, BpdmButton],
  templateUrl: './terms.html',
})
export class Terms {
  readonly sections = Array.from({ length: 12 }, (_, i) => `Section ${i + 1}`);
}
terms.html
<bpdm-dialog title="Terms of service" description="Please review before continuing.">
  <button bpdmButton bpdmDialogTrigger>Terms of service</button>
  <ng-template bpdmDialogBody>
    <div class="space-y-3">
      @for (s of sections; track s) {
        <p>{{ s }}: the full text of this clause goes here, and the panel scrolls when it overflows.</p>
      }
    </div>
  </ng-template>
  <ng-template bpdmDialogFooter>
    <button bpdmButton bpdmDialogClose>I agree</button>
  </ng-template>
</bpdm-dialog>

With a dropdown inside

Other overlays — Select, MultiSelect, TreeSelect, popovers, tooltips — work correctly inside the dialog (focus and layering are handled), so nested pickers behave as expected.

dialog-dropdown.tsx
import { Dialog, DialogClose } from '@bpdm/ui/dialog';
import { Button } from '@bpdm/ui/button';
import { Select } from '@bpdm/ui/select';
import { MultiSelect } from '@bpdm/ui/multi-select';
import { TreeSelect } from '@bpdm/ui/tree-select';

const plans = [
  { value: 'free', label: 'Free' },
  { value: 'pro', label: 'Pro' },
  { value: 'enterprise', label: 'Enterprise' },
];
const frameworks = [
  { value: 'react', label: 'React' },
  { value: 'angular', label: 'Angular' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
];
const tree = [
  { value: 'frontend', label: 'Frontend', children: [
    { value: 'react', label: 'React' },
    { value: 'angular', label: 'Angular' },
  ] },
  { value: 'backend', label: 'Backend', children: [
    { value: 'node', label: 'Node.js' },
    { value: 'go', label: 'Go' },
  ] },
];

export function ConfigureProject() {
  return (
    <Dialog
      trigger={<Button>Configure project</Button>}
      title="Configure project"
      footer={
        <DialogClose asChild>
          <Button>Confirm</Button>
        </DialogClose>
      }
    >
      <div className="flex flex-col gap-3">
        <Select options={plans} defaultValue="pro" aria-label="Plan" />
        <MultiSelect options={frameworks} defaultValue={['react']} aria-label="Frameworks" placeholder="Frameworks" />
        <TreeSelect options={tree} defaultValue={['react']} aria-label="Stack" placeholder="Stack" />
      </div>
    </Dialog>
  );
}
configure-project.ts
import { Component } from '@angular/core';
import {
  BpdmDialog,
  BpdmDialogTrigger,
  BpdmDialogBody,
  BpdmDialogFooter,
  BpdmDialogClose,
  BpdmButton,
  BpdmSelect,
  BpdmMultiSelect,
  BpdmTreeSelect,
} from '@bpdm/ng';

@Component({
  selector: 'configure-project',
  imports: [
    BpdmDialog,
    BpdmDialogTrigger,
    BpdmDialogBody,
    BpdmDialogFooter,
    BpdmDialogClose,
    BpdmButton,
    BpdmSelect,
    BpdmMultiSelect,
    BpdmTreeSelect,
  ],
  templateUrl: './configure-project.html',
})
export class ConfigureProject {
  readonly plans = [
    { value: 'free', label: 'Free' },
    { value: 'pro', label: 'Pro' },
    { value: 'enterprise', label: 'Enterprise' },
  ];
  readonly frameworks = [
    { value: 'react', label: 'React' },
    { value: 'angular', label: 'Angular' },
    { value: 'vue', label: 'Vue' },
    { value: 'svelte', label: 'Svelte' },
  ];
  readonly tree = [
    { value: 'frontend', label: 'Frontend', children: [
      { value: 'react', label: 'React' },
      { value: 'angular', label: 'Angular' },
    ] },
    { value: 'backend', label: 'Backend', children: [
      { value: 'node', label: 'Node.js' },
      { value: 'go', label: 'Go' },
    ] },
  ];
}
configure-project.html
<bpdm-dialog title="Configure project">
  <button bpdmButton bpdmDialogTrigger>Configure project</button>
  <ng-template bpdmDialogBody>
    <div class="flex flex-col gap-3">
      <bpdm-select [options]="plans" defaultValue="pro" aria-label="Plan" />
      <bpdm-multi-select [options]="frameworks" [defaultValue]="['react']" aria-label="Frameworks" placeholder="Frameworks" />
      <bpdm-tree-select [options]="tree" [defaultValue]="['react']" aria-label="Stack" placeholder="Stack" />
    </div>
  </ng-template>
  <ng-template bpdmDialogFooter>
    <button bpdmButton bpdmDialogClose>Confirm</button>
  </ng-template>
</bpdm-dialog>

Stacked dialogs

Dialogs stack — open one from inside another and it layers on top. Focus-trap, scroll-lock, and Esc always apply to the topmost dialog, and closing it returns you to the one beneath. In React nest a Dialog in another's body; in Angular nest a <bpdm-dialog> inside the outer's ng-template[bpdmDialogBody]. (For opening stacks imperatively from anywhere, see the Dynamic Dialog.)

stacked.tsx
import { Dialog, DialogClose } from '@bpdm/ui/dialog';
import { Button } from '@bpdm/ui/button';

export function StackedDialogs() {
  return (
    <Dialog
      trigger={<Button>Open dialog</Button>}
      title="First dialog"
      description="Open another dialog from inside this one — they stack."
      footer={
        <DialogClose asChild>
          <Button variant="secondary" appearance="ghost">Close</Button>
        </DialogClose>
      }
    >
      <Dialog
        size="sm"
        trigger={
          <Button variant="secondary" appearance="outline" size="sm">Open second dialog</Button>
        }
        title="Second dialog"
        description="This one opened on top of the first — they stack independently."
        footer={
          <DialogClose asChild>
            <Button>Got it</Button>
          </DialogClose>
        }
      >
        Press Esc to close just this one.
      </Dialog>
    </Dialog>
  );
}
stacked.ts
import { Component } from '@angular/core';
import {
  BpdmDialog,
  BpdmDialogTrigger,
  BpdmDialogBody,
  BpdmDialogFooter,
  BpdmDialogClose,
  BpdmButton,
} from '@bpdm/ng';

@Component({
  selector: 'stacked-dialogs',
  imports: [BpdmDialog, BpdmDialogTrigger, BpdmDialogBody, BpdmDialogFooter, BpdmDialogClose, BpdmButton],
  template: `
    <bpdm-dialog title="First dialog" description="Open another dialog from inside this one — they stack.">
      <button bpdmButton bpdmDialogTrigger>Open dialog</button>
      <ng-template bpdmDialogBody>
        <bpdm-dialog size="sm" title="Second dialog" description="This one opened on top of the first — they stack independently.">
          <button bpdmButton variant="secondary" appearance="outline" size="sm" bpdmDialogTrigger>Open second dialog</button>
          <ng-template bpdmDialogBody>Press Esc to close just this one.</ng-template>
          <ng-template bpdmDialogFooter>
            <button bpdmButton bpdmDialogClose>Got it</button>
          </ng-template>
        </bpdm-dialog>
      </ng-template>
      <ng-template bpdmDialogFooter>
        <button bpdmButton variant="secondary" appearance="ghost" bpdmDialogClose>Close</button>
      </ng-template>
    </bpdm-dialog>
  `,
})
export class StackedDialogs {}

Composition

For full control over the layout, compose the primitives instead of the convenience Dialog: DialogRootDialogTrigger + DialogContent (DialogHeader / DialogTitle / DialogDescription / DialogFooter / DialogClose).

dialog-composition.tsx
import {
  DialogRoot,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from '@bpdm/ui/dialog';
import { Button } from '@bpdm/ui/button';

export function Compose() {
  return (
    <DialogRoot>
      <DialogTrigger asChild>
        <Button variant="secondary" appearance="outline">Compose it</Button>
      </DialogTrigger>
      <DialogContent size="md">
        <DialogHeader>
          <DialogTitle>Invite teammates</DialogTitle>
          <DialogDescription>Build the layout from the primitives.</DialogDescription>
        </DialogHeader>
        <div className="px-6 py-2">Full control over header, body, and footer.</div>
        <DialogFooter>
          <DialogClose asChild>
            <Button variant="secondary" appearance="ghost">Cancel</Button>
          </DialogClose>
          <DialogClose asChild>
            <Button>Send invites</Button>
          </DialogClose>
        </DialogFooter>
      </DialogContent>
    </DialogRoot>
  );
}

In Angular the same <bpdm-dialog> already exposes the slots — use the [bpdmDialogBody] and [bpdmDialogFooter] templates (shown above) for custom layouts; there's no separate primitive set to assemble.

dialog-composition.ts
import { Component } from '@angular/core';
import {
  BpdmDialog,
  BpdmDialogTrigger,
  BpdmDialogBody,
  BpdmDialogFooter,
  BpdmDialogClose,
  BpdmButton,
} from '@bpdm/ng';

@Component({
  selector: 'compose',
  imports: [BpdmDialog, BpdmDialogTrigger, BpdmDialogBody, BpdmDialogFooter, BpdmDialogClose, BpdmButton],
  templateUrl: './dialog-composition.html',
})
export class Compose {}
dialog-composition.html
<bpdm-dialog title="Invite teammates" description="Build the layout with the slots.">
  <button bpdmButton variant="secondary" appearance="outline" bpdmDialogTrigger>Compose it</button>
  <ng-template bpdmDialogBody>Full control over the body.</ng-template>
  <ng-template bpdmDialogFooter>
    <button bpdmButton variant="secondary" appearance="ghost" bpdmDialogClose>Cancel</button>
    <button bpdmButton bpdmDialogClose>Send invites</button>
  </ng-template>
</bpdm-dialog>

Internationalization

The dialog renders almost no copy of its own — the title, description, body and footer are entirely your content, so you translate them at the call site. That leaves just two accessibility strings to localise, both via the messages prop:

  • messages.close — the close button's accessible name (aria-label). Default "Close".
  • messages.dialogLabel — the visually-hidden fallback title, used only when you pass no title, so a screen reader still announces the dialog. Default "Dialog". When you do provide a title, this is never shown.

messages is a Partial<DialogMessages> merged over the English defaults, so you override either key or both. Everything mirrors under dir="rtl" with no prop: the close button sits at the inline-end corner, which becomes the top-left under RTL.

The example below is German (de-DE) — translated title / description / buttons, plus localised messages.

dialog-i18n.tsx
import { Dialog, DialogClose } from '@bpdm/ui/dialog';
import { Button } from '@bpdm/ui/button';

export function ProjektBearbeiten() {
  return (
    <Dialog
      trigger={<Button>Projekt bearbeiten</Button>}
      title="Projekt bearbeiten"
      description="Aktualisieren Sie die Details."
      messages={{ close: 'Schließen', dialogLabel: 'Dialog' }}
      footer={
        <>
          <DialogClose asChild>
            <Button variant="secondary" appearance="ghost">Abbrechen</Button>
          </DialogClose>
          <DialogClose asChild>
            <Button>Speichern</Button>
          </DialogClose>
        </>
      }
    >
      <p>Ihr Projekt ist für alle im Arbeitsbereich sichtbar.</p>
    </Dialog>
  );
}
dialog-i18n.ts
import { Component } from '@angular/core';
import {
  BpdmDialog,
  BpdmDialogTrigger,
  BpdmDialogBody,
  BpdmDialogFooter,
  BpdmDialogClose,
  BpdmButton,
  type DialogMessages,
} from '@bpdm/ng';

@Component({
  selector: 'projekt-bearbeiten',
  imports: [BpdmDialog, BpdmDialogTrigger, BpdmDialogBody, BpdmDialogFooter, BpdmDialogClose, BpdmButton],
  templateUrl: './dialog-i18n.html',
})
export class ProjektBearbeiten {
  readonly de: Partial<DialogMessages> = { close: 'Schließen', dialogLabel: 'Dialog' };
}
dialog-i18n.html
<bpdm-dialog
  title="Projekt bearbeiten"
  description="Aktualisieren Sie die Details."
  [messages]="de"
>
  <button bpdmButton bpdmDialogTrigger>Projekt bearbeiten</button>
  <ng-template bpdmDialogBody>
    <p>Ihr Projekt ist für alle im Arbeitsbereich sichtbar.</p>
  </ng-template>
  <ng-template bpdmDialogFooter>
    <button bpdmButton variant="secondary" appearance="ghost" bpdmDialogClose>Abbrechen</button>
    <button bpdmButton bpdmDialogClose>Speichern</button>
  </ng-template>
</bpdm-dialog>

API

Dialog (convenience) / <bpdm-dialog>

PropTypeDefaultDescription
open / defaultOpenbooleanControlled / uncontrolled open state. Angular: [(open)].
onOpenChange(open: boolean) => voidReact — fires on open/close.
triggerReactNodeElement that opens it (React). Angular: [bpdmDialogTrigger].
titleReactNodeHeading (a hidden one is supplied if omitted, for a11y).
descriptionReactNodeSub-heading line.
footerReactNodeFooter area (React). Angular: [bpdmDialogFooter] template.
sizesm | md | lg | xlmdPanel width.
showClosebooleantrueShow the top-right close button.
messagesPartial<DialogMessages>EnglishOverride the a11y strings for i18n (see below).
classNamestring (Angular class)Extra classes on the dialog panel.
onInteractOutside(e) => voidReact — fires on outside interaction; e.preventDefault() to keep it open.
onEscapeKeyDown(e) => voidReact — fires on Esc; e.preventDefault() to keep it open.

DialogMessages:

KeyDefaultDescription
close"Close"The close button's accessible name (aria-label).
dialogLabel"Dialog"Visually-hidden fallback title used only when no title is set.

Primitives (React): DialogRoot, DialogTrigger, DialogClose, DialogOverlay, DialogContent (size, showClose, closeLabel), DialogHeader, DialogFooter, DialogTitle, DialogDescription.

Accessibility

  • Modal semantics: renders role="dialog" with aria-modal="true", so assistive tech treats everything behind it as inert while it's open.
  • Focus is trapped and restored: focus moves into the panel on open and stays within it (Tab/Shift+Tab cycle inside), then returns to the element that opened it — usually the trigger — on close.
  • Scroll is locked: the background page can't scroll while the dialog is open.
  • Esc and backdrop click close it. To keep it open — e.g. an unsaved-changes guard — call e.preventDefault() in onEscapeKeyDown (Esc) or onInteractOutside (backdrop click) in React; in Angular, drive [(open)] yourself and skip the close.
  • Accessible name and description: the title is the accessible name via aria-labelledby, and the description is linked via aria-describedby. When you omit title, a visually-hidden fallback title is supplied so the dialog is never unnamed — translate it with messages.dialogLabel.
  • Close button: the top-right close button carries an accessible name (messages.close, default "Close") and a visible focus-visible ring. Under dir="rtl" it mirrors to the top-left (inline-end) corner.
  • The heading resets its own margin so it aligns regardless of surrounding styles, and the trigger keeps its own role and keyboard behaviour.

On this page