On this page
Overlays

Dialog released

A window over the page: what is behind it waits, focus stays inside, Escape closes it.

  • Import@pacit/components/dialog
  • Selectorpct-dialog

Implements the W3C ARIA APG Modal Dialog pattern ↗

dialog.demo.ts — the same file runs here and is highlighted under Code

Start here

Usage

One import per component, and the shortest possible use — the package itself is installed once, on the start page.

Import this component
import { PctDialog } from '@pacit/components/dialog';
ExportedWhat it is
PctAutofocusdirective[pctAutofocus]Which element inside a dialog takes focus when it opens.
PctDialogcomponentpct-dialogA modal dialog: a panel that takes focus, and a page that stops answering while it is up.
PctDialogCloseReasontype'escape' | 'backdrop' | 'close' | 'api'Why a dialog closed. A boolean says *that* it closed and nothing else, and the two questions an application asks a modal — "did the user agree" and "did the user get out of it" — are both answered by this and by nothing in open.
<pct-dialog heading="Project settings" [(open)]="open">
  <p>Everything outside this panel is inert while it stands.</p>
</pct-dialog>
Examples

Every state, running

Each example is the component itself, not a screenshot. Open the code under any of them.

A dialog that insists

closeOnBackdrop and closeOnEscape are on by default, because a modal that traps is a modal people hate. Turn them off for the one case that earns it — an unsaved form — and (closed) still tells you how the reader got out.

Closed by: not opened yet

API

What it reads, and what it writes back

Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.

A modal dialog: a panel that takes focus, and a page that stops answering while it is up.

Inputs

NameTypeWhat it does
opentwo-waydefault falsebooleanWhether the dialog is up. A model, because both directions are ordinary: an application opens it, and the dialog closes itself on Escape, on the backdrop and on its own button.
inlinedefault falsebooleanWhether the panel is drawn where the consumer wrote the tag instead of in an overlay. The same <ng-template> either way — the same box, the same parts, the same stylesheet — so the two modes cannot drift apart the way a second hand-written panel would.
headingdefault ''stringThe visible title, rendered as the panel's heading and used as its accessible name. A dialog with no name is a dialog a screen reader announces as "dialog" and nothing else, so an empty one is reported in dev mode rather than passed over.
ariaLabeldefault ''stringThe accessible name of a dialog with no visible heading — an INPUT and not an attribute on the tag, for the select's reason: role="dialog" sits on the panel inside the overlay, and the host carries no role at all (req-a11y-built-in).
ariaLabelledbydefault ''stringAs ariaLabel, for a name that already stands somewhere inside the dialog's content.
closeOnEscapedefault truebooleanWhether Escape closes it.
closeOnBackdropdefault truebooleanWhether a press on the surface around the panel closes it.
closeButtondefault truebooleanWhether the panel draws a close button of its own in the header.

Outputs

NamePayloadWhen it fires
closedPctDialogCloseReasonWhy it closed. The reason is what an application acts on — escape and backdrop are a withdrawal, the content's own buttons are an answer — and open carries none of it.

What it writes on the element

The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.

AttributeBound toNote
classpct-dialog
styledisplay: contentsThe host draws nothing of its own. As an overlay it renders nothing at all and stays in the tree because it is what the panel reads its severed properties from; inline it holds the panel, and display: contents is then what puts that panel exactly where the consumer's markup put the tag.
Styling

Parts to select, tokens to override

Two ways in, both stable. No class names to depend on.

Parts

In a test, PctDialogHarness from @pacit/components/testing reads a part by the name below and a state by its attribute — the harness gate holds the names to the package.

PartSelectorWhat it is
backdrop[data-pct-part="backdrop"]the scrim over the page — an overlay dialog only
close[data-pct-part="close"]the close button
content[data-pct-part="content"]the projected body
header[data-pct-part="header"]the row holding the heading and the close button
heading[data-pct-part="heading"]the title the dialog is named by
panel[data-pct-part="panel"]the box the dialog draws

Tokens

Every value the component reads, with the light and the dark default. Override a token at any scope and it cascades like any custom property.

TokenStyles
--pct-dialog-backdrop-alpha40%Opacity of the backdrop.
--pct-dialog-backdrop-bg{pct.scrim}#0f172a · #000000Background of the backdrop.
--pct-dialog-backdrop-blur3pxBlur of the page behind the backdrop.
--pct-dialog-close-bg-hover{pct.surface-100}#f1f5f9 · #1e293bBackground of the close button on hover.
--pct-dialog-close-fg{pct.text-muted}#334155 · #e2e8f0Text colour of the close button.
--pct-dialog-close-fg-hover{pct.text}#0f172a · #ffffffText colour of the close button on hover.
--pct-dialog-close-radius{pct.radius.md}8pxCorner radius of the close button.
--pct-dialog-close-size{pct.target.min}24pxSize of the close button.
--pct-dialog-heading-fg{pct.text}#0f172a · #ffffffText colour of the heading.
--pct-dialog-heading-font-size{pct.font.size.lg}16pxFont size of the heading.
--pct-dialog-heading-font-weight{pct.font.weight.medium}500Font weight of the heading.
--pct-dialog-panel-bg{pct.surface}#ffffff · #0f172aBackground of the panel.
--pct-dialog-panel-border{pct.border-strong}#64748bEdge colour of the panel.
--pct-dialog-panel-fg{pct.text}#0f172a · #ffffffText colour of the panel.
--pct-dialog-panel-gap{pct.space.4}12pxGap between the panel's heading, body and actions.
--pct-dialog-panel-max-width480pxMaximum width of the panel.
--pct-dialog-panel-padding-x{pct.space.5}16pxHorizontal padding of the panel.
--pct-dialog-panel-padding-y{pct.space.5}16pxVertical padding of the panel.
--pct-dialog-panel-radius{pct.radius.md}8pxCorner radius of the panel.
--pct-dialog-panel-shadow{pct.shadow-panel}0 8px 24px rgba(15, 23, 42, 0.14) · 0 8px 24px rgba(0, 0, 0, 0.55)Shadow of the panel.

Theming

A brand override in a few declarations. Scope it to a theme attribute, a page or one container — the component never notices.

[data-theme='brand'] {
  --pct-dialog-panel-bg: #f0fdfa;
  --pct-dialog-panel-fg: #134e4a;
  --pct-dialog-heading-fg: #0f766e;
}
Accessibility

Measured, not assumed

Pattern: Modal Dialog — role="dialog" with aria-modal="true", focus trapped in the panel and given back to whatever opened it, Escape from the closing stack. With inline it steps out of that pattern on purpose and is a plain non-modal role="dialog" — see Inline below

Keyboard

keyeffecttest
Escapeclose, with the reason escape; off via closeOnEscapeapps/sandbox-e2e/src/dialog.spec.ts
Tabwalks the panel and never leaves itapps/sandbox-e2e/src/dialog.spec.ts
Shift+Tabthe same walk backwardsapps/sandbox-e2e/src/dialog.spec.ts

Everything else belongs to the controls inside the content. Escape is not the dialog's alone: a panel opened within it is a later overlay in the stack and answers the key first — the select's list closes, the dialog stays (apps/sandbox-e2e/src/dialog.spec.ts, "Escape closes the list first and the dialog second").

The whole table is the overlay's. An inline dialog is in no stack, so Escape passes it by and closeOnEscape governs nothing; Tab walks in and straight out again, because the panel is text in the page and there is no trap to hold it. Its ways out are the close button and whatever the content holds (libs/components/dialog/src/dialog.spec.ts).

What is measured, and what is not yet

Every line is a test in three engines or a gate on every commit. The gaps are listed the same way, each with the requirement that will close it.

MeasuredARIA APG pattern in the JSDoclibs/components/dialog/src/dialog.ts — the pattern named in the class comment
MeasuredKeyboard map testedapps/sandbox-e2e/src/dialog.spec.ts, libs/components/dialog/src/dialog.spec.ts
Measuredaxe auditapps/sandbox-e2e/src/a11y.spec.ts — the /dialog view, and separately an open panel, which the walk over the routes cannot reach
MeasuredVisual screenshotapps/sandbox-e2e/src/visual.spec.ts — dialog-open and dialog-open-rtl, the whole viewport rather than the panel: the veil over the page is part of what this component draws — with the sandbox's navigation blanked by the stage, so a row in that list is not a pixel here (req-quality-e2e)
Measuredforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts — the mode has no translucent system colour, so the veil says Canvas outright and the panel keeps a CanvasText edge; without it a dialog there is a rectangle of page on a rectangle of page
By designprefers-reduced-motionnone — deliberately: the dialog animates nothing. There is no duration to reduce, and an enter transition arrives with the tooltip, where hover/focus parity forces one anyway
MeasuredTouch target ≥ 24×24 pxlibs/tokens/src/component.dialog.json — the close button's box is {pct.target.min}, not the 16 px cross it draws
By designSize axisnone — deliberately: a dialog has no control height. req-api-size is about --pct-control-height-*, and the only width a modal has is --pct-dialog-panel-max-width, which a consumer overrides
GapDensity axisnone — gap. The same one every component here has (req-token-density)
MeasuredRTLapps/sandbox-e2e/src/dialog.spec.ts — the direction carried onto the panel, which an overlay severs (lesson-35); plus the dialog-open-rtl screenshot. The scroll lock's scrollbar compensation is padding-inline-end, so it gives back the edge that moved
MeasuredSSR + hydrationapps/sandbox-e2e/src/hydration.spec.ts — the /dialog view renders and hydrates with no NG05xx; a closed dialog contributes no markup at all. The inline half is read in libs/components/dialog/src/dialog.spec.ts instead, under the flag Angular's own afterNextRender reads (ngServerMode): the inline panel is in the markup, the overlay panel is not
MeasuredInline (inline)libs/components/dialog/src/dialog.spec.ts — twelve cases, and most of them read an ABSENCE: no veil, no aria-modal, no inert, no scroll lock, no capture, no Escape, and no warning about ways out that do not exist. Plus the crossing, which is the shape the input is for: a dialog that turns from a layer into a section while it is open keeps its state, hands the page back and emits no closed — it did not close, it changed where it draws
By designFormsnone — deliberately: a dialog is not a form control. It holds no value, implements no FormValueControl, and the controls inside its content are the ones bound to a form
MeasuredFocus: trap, capture, restoreapps/sandbox-e2e/src/dialog.spec.ts — Tab never leaves the panel, focus goes back to the opener, and pctAutofocus decides what takes it. Measured in a browser and nowhere else: jsdom has no layout for the interactivity checker to read. Their ABSENCE inline needs no layout and is read in the unit spec: the keyboard does not move when the panel appears
MeasuredThe background goes inertapps/sandbox-e2e/src/dialog.spec.ts (the pointer, the keyboard and a script all refused) + libs/components/core/src/core.spec.ts (which elements are marked). Two places for one behaviour, the same split PctFocusStays lives with. Inline nothing is marked at all, and the counter behind it stays at zero
MeasuredThe scroll lockapps/sandbox-e2e/src/dialog.spec.ts — the wheel moves nothing while it is up and moves again after, plus the scrollbar's width handed back to the layout (visible in webkit alone; blink and gecko run here with overlay scrollbars and a gutter of 0) — the one coin toss this case ever showed (WebKit, 2026-09-02) was the READING of the baseline and not the lock, and the item that recorded it named the wrong step (lesson-149, lesson-164)
MeasuredParts in the inventorylibs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts)
MeasuredTokens + contrast.policy.jsonlibs/tokens/src/contrast.policy.json — five pairs, of which the one worth having is "the panel's edge against the dimmed page": not "is the veil readable" (it carries no text) but "can you see where the dialog ends"
MeasuredStrings through PCT_TEXTStools/check-texts.mjs + libs/components/dialog/src/dialog.spec.ts — an application's own close label replaces the default and leaves the rest of the texts alone
MeasuredSize budgetlibs/components/size.snapshot.md — the ./dialog row, and the ./core row beside it: measured the day it landed, 2026-08-20, the modal half was a service every entrypoint carried, 1041 B of it, whether or not it ever injected one
MeasuredScreen-reader logRead 2026-09-28 from docs/acr/at/ — three readers, whole sandbox, and the act that opens it. Two of the three announce the modal as it takes focus: NVDA Project settings, dialog, VoiceOver Project settings web dialog with 4 items Close button — the name, the role, and for VoiceOver the size and the control focus landed on. Orca says nothing with focus on the close button, on the desk and on the runner alike, and speaks again only on Escape (main content · Open the dialog · button.) — the one silence of the nine acts that repeats in every reading, and the plan holds it as a finding. Whether a live region behind the modal still reaches the user is a second act, not this one, and stays unread. VoiceOver's Escape closed nothing here: that reader takes the key
Measureddocs page/components/dialog on the published site — prerendered, the demo's own source is the code tab
Evidence

What the repository holds for this component

Read at build time from the tracked snapshots, not typed here.

79.03%mutation score98 of 124 mutants killed
15 × 3e2e cases × enginesplus the shared audits: axe, forced colors, preferences, size, hydration
5colour pairs measuredlight and dark, at every token build
2visual baselinesChromium, the sandbox's own pictures
3elements in the preview3 listeners · 1 render to settle · 3220 µs in jsdom, held exactly but for the clock

Why it is built this way

Not a native <dialog showModal()>, and that is a measurement rather than a preference: the native element makes every CDK overlay on the page inert while it is open, the top layer included, so a pct-select inside one would have a panel nobody could click, focus or reach by Tab (lesson-89, 0029).

Decisions behind it

  • 0066A panel is a layer or a region, and the consumer says which
  • 0029A modal is an overlay, not a <dialog> — the platform's modal is not composable with ours
  • 0024The overlay layer carries what an overlay severs, and borrows the closing stack
  • 0025A panel says whether it takes focus, and the rest of the focus layer waits for a consumer
  • 0013No headless core / skin split
  • 0047A drawer is a region of the page, not a layer over it
  • 0007Configuration apart from texts
  • 0028An icon set is a component, and an icon's box is the contract
  • lesson-89The platform's modal is not composable with the library's overlays
  • lesson-149Two equal readings are not stillness: a scroll comes to rest in frames, not in round trips
  • lesson-164A flake's record names the line the stack names, not the line the story ends on

Known limitations

  • No animation. A modal that appears instantly is honest and cheap; the alternative is an enter/leave pair, and a leave transition means the panel outlives its own open === false, which is a second state to get wrong. It arrives with the tooltip, where hover/focus parity forces a real enter/leave anyway. - One size. --pct-dialog-panel-max-width is a token a consumer overrides; there is no size input, because a dialog's width has nothing to do with the sm/md/lg control-height axis the rest of the library shares.
  • No alertdialog. The role for an interruption that has to be read at once is a separate one, and pretending a boolean input covers it would be worse than not offering it.
  • No stacking policy. Two dialogs open at once work — the top layer orders them and the background counter holds — but nothing says they should. A wizard that opens a confirm over a form is legitimate; four deep is a design somebody should be arguing with, and this component does not.
  • Inline drops two of the three ways out, and nothing says so at runtime. closeOnEscape and closeOnBackdrop govern nothing there — the first travels the closing stack an inline panel is not in, the second names a surface it does not draw — and no dev-mode message reports it: the responsive shape the input exists for BINDS inline and leaves both inputs at their defaults, so a warning would fire on half of every render and mean nothing on either half. The JSDoc and the row above say it instead.
  • An inline dialog announces itself to nobody. No trap means no capture, so [pctAutofocus] decides nothing and the keyboard stays where the user left it. The panel keeps tabindex="-1", so an application that wants to move focus to a section it has just revealed can — this component will not do it for them, because a region of a page that took the keyboard as it rendered would be the defect and not the feature.
  • No inline demo in the sandbox yet. The browser battery above — axe, the two screenshots, forced colours, RTL, the scroll lock, the inert background — walks the sandbox, and there is no inline dialog in it, so every one of those rows is about the modal. What covers the mode today is the unit spec, and jsdom is enough for it: what inline does is mostly not doing things, and an absence needs no layout to be read.
  • The api reason cannot tell one button from another. Every close that does not come from Escape, the veil or the built-in button reports api, including the consumer's own "Save" and "Cancel". Those two are the content's, and the content already knows which one was pressed.