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
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>
);
}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 {}<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.
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" />
</>
);
}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 {}<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.
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>
</>
);
}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);
}<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.
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>
);
}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}`);
}<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.
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>
);
}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' },
] },
];
}<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.)
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>
);
}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: DialogRoot → DialogTrigger + DialogContent
(DialogHeader / DialogTitle / DialogDescription / DialogFooter /
DialogClose).
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.
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 {}<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 notitle, so a screen reader still announces the dialog. Default"Dialog". When you do provide atitle, 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.
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>
);
}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' };
}<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>
| Prop | Type | Default | Description |
|---|---|---|---|
open / defaultOpen | boolean | — | Controlled / uncontrolled open state. Angular: [(open)]. |
onOpenChange | (open: boolean) => void | — | React — fires on open/close. |
trigger | ReactNode | — | Element that opens it (React). Angular: [bpdmDialogTrigger]. |
title | ReactNode | — | Heading (a hidden one is supplied if omitted, for a11y). |
description | ReactNode | — | Sub-heading line. |
footer | ReactNode | — | Footer area (React). Angular: [bpdmDialogFooter] template. |
size | sm | md | lg | xl | md | Panel width. |
showClose | boolean | true | Show the top-right close button. |
messages | Partial<DialogMessages> | English | Override the a11y strings for i18n (see below). |
className | string (Angular class) | — | Extra classes on the dialog panel. |
onInteractOutside | (e) => void | — | React — fires on outside interaction; e.preventDefault() to keep it open. |
onEscapeKeyDown | (e) => void | — | React — fires on Esc; e.preventDefault() to keep it open. |
DialogMessages:
| Key | Default | Description |
|---|---|---|
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"witharia-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.
Escand backdrop click close it. To keep it open — e.g. an unsaved-changes guard — calle.preventDefault()inonEscapeKeyDown(Esc) oronInteractOutside(backdrop click) in React; in Angular, drive[(open)]yourself and skip the close.- Accessible name and description: the
titleis the accessible name viaaria-labelledby, and thedescriptionis linked viaaria-describedby. When you omittitle, a visually-hidden fallback title is supplied so the dialog is never unnamed — translate it withmessages.dialogLabel. - Close button: the top-right close button carries an accessible name
(
messages.close, default"Close") and a visible focus-visible ring. Underdir="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.