Dev view
Modal
A transient overlay surface that suspends the underlying view and demands attention or input before the user can return to the rest of the page. Synonymous with "dialog" in the ARIA Authoring Practices Guide; "modal" emphasises the *modality* (the page is blocked) over the dialog box itself.
When to use
Use
When the user must focus on a single decision or input that blocks the underlying flow — confirmations, destructive actions, multi-step wizards that must run to completion. Distinguished from non-blocking surfaces by the focus trap and the `inert` background.
Avoid
For non-blocking notifications — that is `Toast` or `Banner`. For contextual content tied to a trigger — that is `Popover`. For side-pane content that does not require completion before returning to the page — that is `Drawer`. For inline disclosure of secondary detail — that is `Disclosure` or `Accordion`.
Versus related
- drawer
`Drawer` slides from a viewport edge and may be modal or non-modal; `Modal` always centres and is always modal. Drawer is preferred for navigation, filters, and side panels that accompany the main flow rather than blocking it.
- popover
`Popover` is non-modal, anchored to a trigger, and dismissable by outside-click without ceremony. `Modal` is modal, viewport-centred, and dismissal requires explicit user intent (Escape, close button, or commit).
- alert
`Alert` is a non-blocking inline message that announces to assistive tech via `aria-live`. `Modal` with `variant: alertdialog` is a blocking variant for confirmations that demand user response; do not confuse the two.
Implementations
How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.
Dialog (from @angular/cdk/dialog) // dialog.component.ts (consumer)import { Component, inject } from '@angular/core';import { Dialog, DIALOG_DATA, DialogRef } from '@angular/cdk/dialog';
@Component({ selector: 'app-page', template: ` <button (click)="openDialog()">Open</button> `,})export class PageComponent { private readonly dialog = inject(Dialog);
openDialog() { const ref = this.dialog.open<EditProfileResult>(EditProfileDialog, { data: { userId: 42 }, ariaLabel: 'Edit profile', hasBackdrop: true, disableClose: false, restoreFocus: true, autoFocus: 'first-tabbable', width: '480px', maxWidth: '90vw', });
ref.closed.subscribe((result) => { if (result?.saved) { // commit-with-result pattern } }); }}
// edit-profile.dialog.ts (the dialog content component)@Component({ selector: 'app-edit-profile-dialog', template: ` <div class="dialog-shell"> <h2 id="dialog-title">Edit profile</h2> <p>Make changes to your profile.</p> <!-- body content --> <div class="dialog-actions"> <button type="button" (click)="ref.close()">Cancel</button> <button type="button" (click)="save()">Save</button> </div> </div> `,})export class EditProfileDialog { readonly data = inject<{ userId: number }>(DIALOG_DATA); readonly ref = inject<DialogRef<EditProfileResult>>(DialogRef);
save() { this.ref.close({ saved: true }); }}Divergence
| From | Type | → To | Rationale |
|---|---|---|---|
anatomy[backdrop] | renamed | CDK Overlay backdrop (config: hasBackdrop, backdropClass) | Backdrop is a first-class CDK Overlay concern, configured via the `hasBackdrop: true` flag on DialogConfig. The CDK manages the backdrop element creation, pointer-event capture, and animation hooks. Distinct from Radix and Headless UI which delegate backdrop entirely to consumer-composition. Consumers style the backdrop via the `backdropClass` config option (CSS class applied to the backdrop element). |
anatomy[container] | reshaped | Dialog service (`dialog.open()` imperative API) + CDK overlay container + consumer-provided content component | Canonical container is one slot bearing dialog role + focus trap + the rendered surface. CDK splits this across three layers: (a) Dialog service — singleton DI-injected entry point; `dialog.open(ContentComponent, config)` is the imperative API (no declarative `<Dialog>` markup), (b) CDK overlay container — auto-managed; provides ARIA role (default `dialog`), focus trap, restoreFocus, backdrop, position strategy, (c) Consumer's content component — the dialog body's actual rendered template; receives `DIALOG_DATA` and `DialogRef` via Angular DI. Imperative-first vs declarative-first is the most fundamental divergence between CDK and Radix/Headless UI. |
anatomy[header] | omitted | — | No header primitive. Consumer's content component template contains whatever heading + close-affordance composition the consumer wants. (Angular Material's MatDialog ships `<mat-dialog-title>` plus actions slots; pure CDK Dialog does not.) |
anatomy[title] | omitted | — | No title primitive in pure CDK Dialog. The accessible name comes from the `ariaLabel` config option (or `ariaLabelledBy` referencing an element id in the consumer's template). Consumer's template renders the visible title via whatever heading element they choose. The CDK does not auto-wire `aria-labelledby` from a title slot to the container — it is the consumer's responsibility to either set `ariaLabel` or `ariaLabelledBy` on the DialogConfig. |
anatomy[close-button] | omitted | — | No close-button primitive. Consumer wires `(click)="ref.close()"` on whatever button they choose; the DialogRef is injected via Angular DI. Escape dismissal is handled by CDK based on `disableClose: false` config (default true for Escape). Backdrop-click dismissal is also auto- handled when `disableClose: false` and `hasBackdrop: true`. |
anatomy[body] | reshaped | Consumer's content component template (the entire component template IS the body) | Canonical body is one slot for the dialog payload. CDK passes a consumer-defined component class to `dialog.open()`; that component's template is rendered inside the overlay container. There is no body slot to compose into — the consumer's whole component is the body. For prose vs form vs list bodies, the consumer creates different content components. This is significantly more component-creation overhead than React's children prop, but matches Angular's component-first architecture. |
anatomy[footer] | omitted | — | No footer primitive. Consumer's content component template includes whatever action-row composition. (Angular Material ships `<mat-dialog-actions>` with built-in flex layout + align-end variants; CDK alone does not.) |
axes.variants[alertdialog] | reshaped | config option `role: 'alertdialog'` | CDK exposes the ARIA role via DialogConfig.role property; setting `role: 'alertdialog'` produces the alert-dialog semantic with the same Dialog primitive. Distinct from Radix (separate @radix-ui/react-alert-dialog package) and Headless UI (consumer-composed via role attribute). CDK's config- driven approach is the cleanest of the three for this specific variant. |
axes.variants[fullscreen] | reshaped | config options `width: '100vw'`, `height: '100vh'`, `maxWidth: '100vw'` | No fullscreen variant; consumer sets viewport-filling dimensions via DialogConfig. Same shape as Radix and Headless UI but here the dimensions live in the imperative config object rather than in CSS classes on a rendered element. |
axes.properties[size] | reshaped | DialogConfig `width` / `maxWidth` / `height` / `maxHeight` properties | Canonical `size: sm | md | lg | xl | full` enum. CDK uses free-form CSS-length strings on DialogConfig. A design-system layer above CDK could expose the size enum and translate it to specific px / vw values; canonical CDK alone is lower-level. |
axes.properties[dismissible] | renamed | DialogConfig `disableClose: boolean` (inverted polarity) | Direct one-to-one mapping with inverted polarity: `dismissible: true` ⇔ `disableClose: false` (default); `dismissible: false` ⇔ `disableClose: true`. The CDK flag controls both Escape-key and backdrop-click dismissal together; canonical models the two paths as one switch already, so the contracts align cleanly. |
axes.properties[scrollBehavior] | reshaped | DialogConfig `scrollStrategy` (CDK Overlay scroll strategy: noop / block / close / reposition) | Canonical `inside | outside` two-value enum. CDK's scrollStrategy is the broader Overlay-level abstraction with four values (noop, block, close, reposition). For modal dialogs the canonical default maps to `block` (page scroll locked while dialog open); `inside` scrollBehavior (canonical) is achieved via CSS overflow on the consumer's content template plus `block` scroll strategy. |
events[openChange] | reshaped | DialogRef `afterOpened` and `closed` Observables (rxjs) | CDK exposes lifecycle events via rxjs Observables on DialogRef: - `dialogRef.afterOpened()` emits void after enter animation completes, - `dialogRef.closed` emits the close-result value after exit animation completes. Consumers subscribe via standard rxjs operators. There is no single onOpenChange-style boolean callback — the lifecycle is decomposed into discrete observables. Closed-with-result is a feature CDK ships natively (matches Vue's `<form method="dialog">` returnValue concept) — Radix and Headless UI achieve this via consumer state management. |
events[dismiss] | reshaped | DialogRef `closed` Observable with optional result discriminator | Canonical `dismiss` carries `{ reason: 'escape' | 'backdrop' | 'closeButton' }`. CDK's `closed` Observable carries the consumer-supplied result value. To recover the canonical reason, consumers pass discriminated result objects: `ref.close()` (no result, dismissed) vs `ref.close({ saved: true })` (committed). The CDK does NOT expose which dismissal path triggered the close (Escape vs backdrop vs explicit ref.close()) — that information is lost unless the consumer wires Escape and backdrop handlers to call `ref.close({ reason: 'escape' })` etc. explicitly. |
motion.durations | reshaped | @angular/animations `trigger`/`transition` blocks on the consumer's content component (or CDK Overlay animation config) | Canonical motion.durations.{open, close, backdrop} maps to Angular's animation system. The consumer's content component declares animations via `@Component({ animations: [trigger( 'enterExit', [...]) ] })`. Backdrop animation is configured separately via CDK Overlay's animation hooks or via CSS on the `backdropClass` element. Significantly more orchestration than the data-state-attribute pattern (Radix) or the TransitionChild composition (Headless UI) — Angular animations are powerful but verbose. |
motion.reducedMotionFallback | omitted | — | Angular animations does not auto-detect `prefers-reduced-motion: reduce`. Consumers either inject MediaMatcher and conditionally disable the trigger, or use CSS-level animation media queries. Canonical `instant` fallback is implementable but consumer-wired. |
axes.states.transitions | reshaped | DialogRef state surfaced via Observables (`afterOpened`, `closed`); intermediate animating-states accessible via Angular animation lifecycle hooks (`@enterExit.start`, `@enterExit.done`) | Canonical four-state graph. CDK realises the graph through two layers: Observable lifecycle (open → opened, closing → closed) and Angular animation lifecycle hooks (start / done events on the animation trigger). Consumers needing the `opening` and `closing` intermediate states bind to the animation trigger's `(@trigger.start)` and `(@trigger.done)` events. More verbose than data-state attributes but more explicit. |
Why this audit reads the way it does
Angular CDK Dialog is the most-divergent of the three audited libraries (Radix React, Headless UI Vue, CDK Angular). Where Radix and Headless UI follow declarative-component-tree shapes with consumer-composed slots, CDK uses an imperative service- based API with consumer-defined content components passed by reference to `dialog.open()`. This is idiomatic Angular and matches the framework's component-first architecture, but it reshapes the canonical anatomy considerably — most named slots (header, title, close-button, body, footer) live entirely in the consumer's content component template rather than in composable library primitives. Notable CDK strengths over Radix and Headless UI: 1. Built-in backdrop primitive (`hasBackdrop` config) — no consumer composition required. 2. Native commit-with-result pattern via DialogRef.closed Observable — Vue-style `<form method="dialog">` returnValue baked into the API. 3. Config-driven role variant (`role: 'alertdialog'`) — no separate package needed (vs Radix), no consumer-composition needed (vs Headless UI). 4. Comprehensive CDK Overlay scroll-strategy abstraction — four named strategies cover canonical scrollBehavior plus more. Notable CDK weaknesses vs Radix and Headless UI: 1. Imperative-first API requires consumer to write a separate content component for every dialog instance — significantly more boilerplate than `<Dialog><DialogPanel>...</DialogPanel></Dialog>`. 2. `closed` Observable does not surface dismissal reason — consumers wire Escape and backdrop handlers manually to recover the canonical { reason } payload. 3. Angular animation system is verbose for declaring open/ close transitions (vs Radix's data-state-attribute hooks or Headless UI's TransitionChild props). Pattern-recap across all three audits: every library stops at the accessibility primitive; design-system specifics (size scales, motion timing, dismissible flags, fullscreen variants) are a consumer-composed layer above the library. The three differ on *how* consumer-composition is shaped — declarative children (Radix), declarative wrappers + render- props (Headless UI), imperative service + consumer content components (CDK).
Dialog <script setup>import { ref } from 'vue';import { Dialog, DialogPanel, DialogTitle, DialogDescription, TransitionRoot, TransitionChild,} from '@headlessui/vue';const isOpen = ref(false);</script>
<template> <button @click="isOpen = true">Open</button>
<TransitionRoot :show="isOpen" as="template"> <Dialog @close="isOpen = false" class="relative z-50"> <TransitionChild as="template" enter="transition-opacity duration-200" enter-from="opacity-0" enter-to="opacity-100" leave="transition-opacity duration-150" leave-from="opacity-100" leave-to="opacity-0" > <div class="fixed inset-0 bg-black/40" aria-hidden="true" /> </TransitionChild>
<div class="fixed inset-0 grid place-items-center p-4"> <TransitionChild as="template" enter="transition duration-200" enter-from="opacity-0 scale-95" enter-to="opacity-100 scale-100" leave="transition duration-150" leave-from="opacity-100 scale-100" leave-to="opacity-0 scale-95" > <DialogPanel class="bg-white rounded-lg p-6 max-w-md w-full"> <DialogTitle as="h2">Edit profile</DialogTitle> <DialogDescription> Make changes to your profile. </DialogDescription> {/* body content */} <div class="mt-4 flex justify-end gap-2"> <button type="button" @click="isOpen = false">Cancel</button> <button type="button" @click="save">Save</button> </div> </DialogPanel> </TransitionChild> </div> </Dialog> </TransitionRoot></template>Divergence
| From | Type | → To | Rationale |
|---|---|---|---|
anatomy[backdrop] | omitted | — | Headless UI does not ship a backdrop primitive. The consumer renders an arbitrary `<div>` with full-viewport positioning and animates it via TransitionChild. The aria-hidden attribute is the consumer's responsibility; Headless UI's Dialog does not mark sibling content inert (it does manage focus trap on DialogPanel, but page-context inerting is consumer-implemented via the Dialog's `:initialFocus` plus body-scroll-lock). |
anatomy[container] | reshaped | TransitionRoot + Dialog + DialogPanel composition (with TransitionChild for animation) | Canonical container is one slot bearing dialog role + focus trap. Headless UI splits this across: (a) TransitionRoot — owns the show/hide lifecycle and animation coordination, (b) Dialog — owns ARIA role + controlled state + onClose, (c) DialogPanel — owns the actual rendered surface and focus trap. Animation is a separate primitive (TransitionChild) wrapping individual elements; Radix bundles animation hooks into data-state attributes on a single Content element. |
anatomy[header] | omitted | — | No header named slot. Consumer composes a `<div>` containing DialogTitle plus a manual close button. Same omission as Radix Dialog; matches the "consumer composes layout regions" pattern shared across both libraries. |
anatomy[title] | renamed | DialogTitle | Same role; auto wires `aria-labelledby` from the surrounding Dialog without consumer wiring. The `as="h2"` prop renders the title as an h2 element (or any other element); without `as` the default render is `<h2>`. Matches Radix Dialog.Title exactly. |
anatomy[close-button] | omitted | — | No close-button primitive. Consumer wires a `<button>` with `@click="isOpen = false"` (or whatever consumer state-update handler closes the dialog). The Dialog's `@close` event fires automatically on Escape; backdrop click closes when the consumer's backdrop element is wired with a click-to-close handler. Headless UI does not unify these into a single close affordance. |
anatomy[body] | reshaped | DialogDescription plus arbitrary children inside DialogPanel | Canonical body is one slot for the dialog payload. Headless UI splits the semantic description (DialogDescription, auto-wires `aria-describedby`) from arbitrary body content (free children of DialogPanel). For non-prose bodies — forms, lists, custom layouts — DialogDescription may be omitted. Same shape as Radix's split. |
anatomy[footer] | omitted | — | No footer primitive. Action buttons are placed as free children of DialogPanel. Footer alignment, spacing, button-order are consumer styling (typically Tailwind utilities like `mt-4 flex justify-end gap-2`). |
axes.variants[fullscreen] | omitted | — | No fullscreen variant. Consumer renders DialogPanel with `class="fixed inset-0"` to fill the viewport. The canonical responsive block already documents narrow-viewport degrade-to- fullscreen as a CSS layer above the primitive — same situation as Radix. |
axes.variants[alertdialog] | reshaped | Same Dialog primitive with `:initialFocus` set to the cancel button and consumer-applied role="alertdialog" | Headless UI does not ship a separate AlertDialog package (unlike Radix which splits @radix-ui/react-alert-dialog). Consumers achieve alert-dialog semantics by setting `as="div"` plus `role="alertdialog"` on Dialog, plus `:initialFocus` pointing at the cancel button (canonical behaviour for destructive confirmations). The contract holds via consumer composition rather than via a distinct primitive. |
axes.properties[size] | omitted | — | No size prop. Width / max-width / height are consumer CSS on DialogPanel. Canonical `size: sm | md | lg | xl` is a design- system convention; Headless UI is unstyled-by-design and stops at the accessibility primitive. |
axes.properties[dismissible] | reshaped | @close event handler controlled state | Canonical models `dismissible: boolean` as a single switch. Headless UI exposes `@close` as the only dismissal hook — consumer's handler decides whether to actually close (`isOpen = false`) or suppress the close (no state update). For non-dismissible dialogs, consumer ignores the close event conditionally based on in-flight commit state. There is no built-in suppress mechanism; the responsibility shifts to the consumer's handler logic. |
axes.properties[scrollBehavior] | omitted | — | No scrollBehavior prop. Inside-scroll vs outside-scroll is consumer CSS via `overflow` on DialogPanel and outer wrapper. |
events[openChange] | renamed | @close event (and consumer-managed isOpen ref) | Headless UI's @close fires on dismissal (Escape, programmatic). Open-state is owned by consumer (`isOpen = ref(false)`); the Dialog reads `:show` from TransitionRoot. There is no onOpenChange-style boolean callback because the lifecycle is split across Vue ref ownership (open) and Dialog event (close). |
events[dismiss] | reshaped | @close event without reason discrimination | Canonical `dismiss` carries `{ reason: 'escape' | 'backdrop' | 'closeButton' }`. Headless UI's @close fires for Escape + programmatic close. Backdrop click and close-button click are consumer-wired @click handlers — consumer must inspect event source to recover the reason. No unified reason-carrying event. |
motion.durations | reshaped | TransitionChild's enter/leave Tailwind utility classes (or `enter`/`leave` props with custom CSS) | Canonical motion.durations.{open, close, backdrop} maps to TransitionChild's `enter` and `leave` props which accept CSS class names (typically Tailwind transition utilities like `duration-200`). Each TransitionChild instance composes its own timing. Backdrop and panel each get their own TransitionChild — they may animate at different durations, which is more flexible than canonical's single `backdrop` duration token. |
motion.reducedMotionFallback | omitted | — | TransitionChild does not implement `prefers-reduced-motion: reduce` automatically. Consumer applies `motion-safe:` Tailwind variants OR conditionally omits the transition classes via media-query inspection. The canonical `instant` fallback is implementable but consumer-wired. |
axes.states.transitions | reshaped | TransitionRoot's `:show` prop (boolean) plus Dialog's `@close` event; intermediate states (opening/closing) inferred from TransitionChild's render-prop slot scope | Canonical four-state graph (closed → opening → open → closing → closed). Headless UI realises the graph: the `:show` boolean drives the TransitionRoot, which triggers TransitionChild enter/leave animations. Consumers needing the intermediate `opening` or `closing` states inspect TransitionChild's slot scope via `v-slot="{ open }"` — identical to Radix's data-state attribute pattern but surfaced through Vue's render-prop mechanism. |
Why this audit reads the way it does
Headless UI Vue's Dialog is unstyled, low-ceremony, and follows the same accessibility-primitive philosophy as Radix Dialog. The divergences from the canonical Modal closely mirror Radix's divergences (same omitted slots: backdrop, header, footer, close-button; same reshaped slots: container split, body split; same renamed slot: title). The notable differences from the Radix audit are: 1. Animation is a separate composable primitive (TransitionRoot + TransitionChild) rather than bundled hooks on the dialog surface — consumers compose animation orthogonally to the Dialog primitive. 2. State management is more decoupled: the consumer owns the `isOpen` ref; Dialog only emits `@close` and reads `:show` from a parent TransitionRoot. No onOpenChange-style boolean callback. 3. AlertDialog is not a separate package; consumers compose alert-dialog semantics on the same Dialog primitive (via `role="alertdialog"` plus `:initialFocus`). Pattern recap across the two implementation audits (Radix React vs Headless UI Vue): both libraries stop at the accessibility primitive; design-system specifics (size scales, motion timing, dismissible flags, fullscreen variants) are a consumer-composed layer above the library. Both shape the canonical anatomy via "consumer composes layout regions" rather than ship named slots for header / footer.
Dialog import * as Dialog from '@radix-ui/react-dialog';
<Dialog.Root open={open} onOpenChange={setOpen}> <Dialog.Trigger asChild> <Button>Open</Button> </Dialog.Trigger> <Dialog.Portal> <Dialog.Overlay className="DialogOverlay" /> <Dialog.Content className="DialogContent"> <Dialog.Title>Edit profile</Dialog.Title> <Dialog.Description>Make changes to your profile here.</Dialog.Description> {/* body content */} <div className="DialogActions"> <Dialog.Close asChild> <Button variant="ghost">Cancel</Button> </Dialog.Close> <Button>Save</Button> </div> <Dialog.Close asChild> <IconButton aria-label="Close" className="DialogCloseIcon"> <Cross2Icon /> </IconButton> </Dialog.Close> </Dialog.Content> </Dialog.Portal></Dialog.Root>Divergence
| From | Type | → To | Rationale |
|---|---|---|---|
anatomy[backdrop] | renamed | Dialog.Overlay | Same role (presentational scrim that intercepts pointer events). Radix uses "Overlay" terminology; canonical uses "backdrop" because the term is more widely understood across design tools. |
anatomy[container] | reshaped | Dialog.Root + Dialog.Portal + Dialog.Content | Canonical container is one slot bearing both the dialog role and the focus trap. Radix splits this across three components: Root owns controlled-state and accessibility wiring, Portal owns the DOM relocation, Content owns the rendered surface and `role="dialog"`. A consumer composing the canonical anatomy uses all three together. |
anatomy[header] | omitted | — | Radix has no header slot. Title and Close are placed directly inside Content without a wrapping region. The canonical "header" concept maps to a consumer-composed `<div>` containing Dialog.Title and Dialog.Close side by side; the layout is the consumer's job. |
anatomy[title] | renamed | Dialog.Title | Same role; Radix renders an `<h2>` by default and wires `aria-labelledby` automatically. Canonical anatomy says "title"; Radix uses the ARIA-aligned name. |
anatomy[close-button] | renamed | Dialog.Close | Same role. Note that Dialog.Close is an `asChild`-friendly wrapper — consumers pass their own button (`<IconButton>` for the canonical "× in trailing edge of header" pattern). The accessible name comes from the consumer's button, not from Dialog.Close itself. |
anatomy[body] | reshaped | Dialog.Description plus free children | Canonical body is one slot for the dialog payload. Radix splits the semantic description (Dialog.Description, wired to `aria-describedby`) from arbitrary body content (free children of Dialog.Content). For non-prose bodies — forms, lists, custom layouts — Description may be omitted or hidden visually while still providing the accessible description. |
anatomy[footer] | omitted | — | Radix has no footer slot. Action buttons are placed as free children of Dialog.Content. The canonical footer's purpose (group commit/ cancel actions) maps to a consumer-composed `<div>` typically with Tailwind / class-variance-authority styling; alignment and spacing are not part of Radix. |
axes.variants[fullscreen] | omitted | — | Radix Dialog has no built-in fullscreen variant. Consumers achieve fullscreen via CSS on Dialog.Content (`width: 100vw; height: 100vh; max-width: none;`); Radix does not bundle this as a first-class option. The canonical responsive block already documents that narrow viewports degrade non-fullscreen dialogs to fullscreen — at the consumer's CSS layer, not the library's. |
axes.variants[alertdialog] | renamed | AlertDialog (separate Radix package @radix-ui/react-alert-dialog) | Radix splits Dialog and AlertDialog into two packages. AlertDialog forces `role="alertdialog"`, requires Action and Cancel sub-components explicitly, and disables outside-click dismissal. The canonical `alertdialog` variant maps to AlertDialog, not to a Dialog prop. |
axes.properties[size] | omitted | — | Radix Dialog has no size prop. Width / max-width / height are consumer CSS on Dialog.Content. Canonical `size: sm | md | lg | xl` is a design-system convention layered above Radix; not a primitive concern. |
axes.properties[dismissible] | reshaped | onPointerDownOutside / onEscapeKeyDown / onInteractOutside callbacks (preventDefault to suppress) | Canonical models `dismissible: boolean` as a single switch covering Escape + backdrop + close button. Radix does not expose a single flag — instead, Dialog.Content takes three callbacks (onPointerDownOutside, onEscapeKeyDown, onInteractOutside) and a consumer suppresses dismissal by calling `event.preventDefault()` inside the relevant handler. The canonical boolean maps to "preventDefault on all three", not to a single library prop. |
axes.properties[scrollBehavior] | omitted | — | Radix has no `scrollBehavior` prop. Consumers achieve "scroll inside content" via CSS overflow on Dialog.Content; "scroll outside content" (the page scrolls behind the dialog) requires not setting `inert` on the page, which Radix toggles automatically. Canonical `inside | outside` is a design-system layer above the primitive. |
events[openChange] | renamed | onOpenChange (Dialog.Root prop) | One-to-one match. `onOpenChange(open: boolean)` fires after Radix transitions settle. The canonical event payload contract holds. |
events[dismiss] | reshaped | onPointerDownOutside / onEscapeKeyDown handlers on Dialog.Content | Canonical `dismiss` event carries `{ reason: 'escape' | 'backdrop' | 'closeButton' }`. Radix does not surface a unified dismiss event — escape and pointer-down-outside fire on Dialog.Content, close-button click is a regular click on the consumer's button inside Dialog.Close. Recovering the canonical reason requires listening on three separate handlers and synthesising the union type at the consumer layer. |
motion.durations | reshaped | data-state="open" | "closed" CSS attribute selectors on Dialog.Content and Dialog.Overlay | Radix does not ship motion durations. Consumers write CSS that transitions on `[data-state='open']` and `[data-state='closed']` with their own duration tokens. The canonical motion.durations.open / close / backdrop are achievable but the values are entirely consumer-side — the library exposes the state-machine attribute, not the timing. |
motion.reducedMotionFallback | omitted | — | Radix does not implement a reduced-motion behaviour. Consumers apply `@media (prefers-reduced-motion: reduce)` themselves; Radix's data-state mechanism still flips, but no animation is suppressed automatically. The canonical `instant` fallback is implementable but not bundled. |
axes.states.transitions | reshaped | data-state attribute toggled at the canonical-graph edges; consumer observes via CSS animation events | The canonical transitions specify the graph and triggers in prose. Radix realises the graph but does not expose entry/exit events; consumers that need to fire side effects on, e.g., the `opening → open` edge listen to `animationend` on Dialog.Content or poll `data-state`. The graph is honoured; the observability is reshaped. |
Why this audit reads the way it does
Radix Dialog is a low-level, unstyled primitive that implements the canonical Modal's accessibility contract (focus trap, aria-modal, focus restoration, Escape handling) but deliberately stops short of styling, motion timing, and design-system conventions like size scales or dismissible flags. Most divergence rows are *reshaping*, not omission: Radix exposes a structurally different surface (Root/Portal/Content, three-callback dismissal, data-state attributes for motion) that realises the canonical concept through a different shape. The audit's overall pattern: Radix is the implementation layer for the canonical Modal's accessibility primitives; design-system specifics (variant catalogue, motion timing, size scale) are a layer above Radix that consumers compose themselves. AlertDialog is a separate package and a separate audit.
Code anatomy
| Slot | Code slot | Semantic |
|---|---|---|
backdrop | backdrop | presentational-overlay |
container | container | dialog-or-alertdialog |
header | header | heading-region |
title | title | heading |
close-button | close | button |
body | body | prose-or-form |
footer | footer | button-group |
Variants, properties, states
Variants
Structurally different versions of the component.
dialogalertdialogfullscreen Properties
The same component, parameterised.
| Property | Type |
|---|---|
size | sm | md | lg | xl |
dismissible | boolean |
scrollBehavior | inside | outside |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | focus-visible |
data | openingopenclosingclosed |
State transitions
| From | To | Trigger |
|---|---|---|
closed | opening | User activates the trigger that owns the dialog (button click, link activation, programmatic `showModal()`); the previously- focused element is captured for restoration on close. |
opening | open | The enter animation completes (or, under `prefers-reduced-motion: reduce`, immediately after `closed → opening`). Focus moves into the dialog, the focus trap engages, and the rest of the document becomes `inert`. |
open | closing | User dismisses via the close button, Escape, or — when `dismissible` is true — backdrop click; or the primary action commits and programmatically requests close. |
closing | closed | The exit animation completes (or immediately under reduced motion). The dialog is removed from the AT tree, `inert` is released, and focus is restored to the captured trigger. |
Cross-framework expression
| Framework | Structure mechanism | Variant mechanism |
|---|---|---|
| Web Components | native HTMLDialogElement (`<dialog>`) with `showModal()`, plus named slots for header / body / footer | attributes for variant (`role="alertdialog"`) and size (`size="lg"`); CSS state via `[open]` |
| React | portal-based primitives (Radix `Dialog.Root` / `Dialog.Portal` / `Dialog.Content`, or React Aria `Modal` + `Dialog`) compositing the slots as children | props with class-variance-authority for size/variant; `data-state` attribute for transition states |
| Angular (signals) | Angular CDK Dialog (`MatDialog` or `Dialog` from `@angular/cdk/dialog`) with content projection or template-based dialogs | input<'dialog' | 'alertdialog' | 'fullscreen'>() and input<'sm' | 'md' | 'lg' | 'xl'>(); host bindings drive `[attr.role]` |
| Vue | Headless UI `<Dialog>` / `<DialogPanel>` or native `<dialog>` with named slots | defineProps with literal-union types; `:data-state` for transition states |
Events
openChangedismiss
Form integration
- name attribute
- Modal is a container, not a form control — it has no `name` attribute. Forms hosted inside the dialog carry their own `name` attributes on their fields. Native `<dialog>` cooperates with `<form method="dialog">` to submit-and-close in a single user action.
- FormData serialization
- Forms inside the dialog submit normally via their own `<form>`. The dialog itself contributes nothing to FormData. The `<form method="dialog">` pattern is special: the form's submitter button populates `dialog.returnValue` instead of issuing a network request — useful for in-page commit semantics where the dialog closes itself on submit.
- form.reset()
- Forms inside the dialog respond to `form.reset()` independently of the dialog's open/closed state. Closing the dialog via Escape or backdrop click does not reset the form; consumers that want "discard on cancel" semantics call `form.reset()` explicitly in the close handler before unmounting the form.
- HTML5 validation
- Forms inside the dialog use HTML5 validation as normal. Validation failures focus the first invalid field — focus stays inside the dialog because of the focus trap, so the user lands on the field naturally without escaping the modal context.
Performance thresholds
stackDepthopen-modal-count≥1modalsAvoid stacking modals; the canonical maximum is one open dialog at a time. Stacked modals fight for focus traps, leak `inert` handling between layers, and confuse assistive tech that walks the modality tree. Patterns that feel like they need "modal-on-modal" should be redesigned as a step flow within a single modal, or as a sequence of modal → dismiss → modal.
Accessibility
| Slot | Accessibility hint | |
|---|---|---|
backdrop | Backdrop is presentational; do not put role on it. Clicking the backdrop may dismiss the dialog when `dismissible` is true, but keyboard users must always have an explicit close affordance. | |
container | Apply `role="dialog"` (or `role="alertdialog"` for the alert variant) and `aria-modal="true"`. Label via `aria-labelledby` pointing at the title slot, and optionally `aria-describedby` pointing at the body. Focus is trapped here while open and restored to the trigger on close. | |
header | Header is a layout region, not a heading. The actual heading semantics live on the title element. | |
title | Use a real heading element of an appropriate level. Reference it from the container with `aria-labelledby`. Never rely on `aria-label` on the container if a visible title exists. | |
close-button | Provide an accessible name ("Close" or "Close dialog"); a bare × glyph is not announced. The Escape key must do the same thing as clicking this button when the dialog is dismissible. | |
body | Body retains its native semantics (forms stay forms). If the body is the dialog's description, reference its container with `aria-describedby` on the container. | |
footer | Buttons keep native semantics. Document the default focus target (usually the primary action, but the *cancel* action for destructive alerts) so behavior is consistent across uses. |
Accessibility acceptance
Keyboard walk
| Keys | Expected |
|---|---|
Tab | Focus moves to the next focusable inside the dialog. After the last focusable, focus cycles back to the first — never escapes into the page beneath the backdrop while the dialog is open. |
Shift+Tab | Focus moves to the previous focusable inside the dialog. From the first focusable, cycles to the last. |
Escape (when dismissible is true) | Closes the dialog along the canonical `open → closing → closed` path; focus restores to the trigger that opened the dialog. When `dismissible: false` (e.g. an in-flight commit), Escape is a no-op. |
Enter (in form-style dialog) | Activates the default form action (commit) only when the focus is on a form element with implicit submission. Modal does not bind Enter at the container level — that would conflict with multi-line text inputs. |
Screen-reader announcements
| Trigger | Expected |
|---|---|
| Dialog opens (`closed → opening → open`) | Once `aria-modal="true"` is set on the container and focus moves inside, the SR announces the dialog title (via `aria-labelledby`) followed by "dialog" — e.g. "Edit profile, dialog". The previous page context is silenced via `inert` on siblings. |
| Dialog closes (`open → closing → closed`) | Focus returns to the trigger; the trigger's accessible name and role are re-announced. No automatic announcement is required for the close itself — the focus restoration is the cue. |
| Alert dialog opens (alertdialog variant) | Same as dialog but with role `alertdialog`. SR announces "<title>, alert dialog"; the body content (referenced via `aria-describedby`) is read after the title. |
axe-core rules to assert
aria-dialog-namearia-required-attraria-modal-misusecolor-contrastfocus-order-semanticslandmark-uniqueduplicate-id-active
Common mistakes
#modal-no-focus-trap
Tab key escapes the dialog into the page beneath
Without an explicit focus trap, pressing Tab from the last interactive element inside the dialog moves focus to the next element in the document — usually something visually obscured by the backdrop.
Implement a focus trap that cycles Tab/Shift+Tab between the first and last focusable elements inside the dialog. Most modern primitives (Radix Dialog, React Aria, HTMLDialogElement `showModal()`) include this for free; never roll your own without `inert` on the rest of the document.
#modal-no-focus-restore
Focus is lost after the dialog closes
After dismissal, focus lands on `<body>` instead of the trigger that opened the dialog. Keyboard and screen-reader users have to re-orient from the top of the page.
Capture the previously-focused element on open and restore focus to it on close. If the trigger no longer exists (e.g., it was destroyed by the action just confirmed), focus a stable landmark such as the page heading or a confirmation toast.
#modal-escape-not-bound
Escape key does not dismiss a dismissible dialog
`dismissible` is true (the close button works, the backdrop click works) but Escape does nothing — usually because the handler was attached to the backdrop element instead of document-level.
Bind Escape on the dialog container with `keydown` capture, or delegate at document level when the dialog is open. Make the handler the same one bound to the close button so behavior is identical across input modalities.
#modal-backdrop-pointer-leak
Pointer events pass through the backdrop to the page beneath
The backdrop is rendered with `pointer-events: none` (or no backdrop at all), so a click at backdrop coordinates triggers the underlying button. The page is no longer modal.
The backdrop must intercept all pointer events. Native `<dialog>` with `showModal()` does this automatically; custom implementations need an opaque or transparent-but-pointer-active element covering the viewport behind the container.
Figma↔Code mismatches
- 01 Figma
A "modal" component drawn as a centered rectangle with a separate "backdrop" rectangle stacked on the same page
CodeA portal that lifts the dialog out of the DOM tree, with focus trapping, escape key handling, and `inert` on the rest of the document
ConsequenceThe Figma artifact captures the visual treatment but encodes none of the modality. Developers approximating from the Figma file may ship a div that *looks* like a modal but allows tabbing into the page beneath, breaks screen-reader virtual cursors, and routes Escape to the wrong handler.
CorrectTreat the visual layout and the modal *behavior* as separate concerns. The Figma component documents anatomy and visual treatment; the canonical reference and a11y notes document the portal, focus trap, escape, and inert behavior. Designers and developers explicitly review both artefacts together.
- 02 Figma
Close icon drawn as a static glyph in the header
CodeAn accessible <button> with an ARIA label and matching escape-key handler
ConsequenceThe icon is announced as nothing or as the literal "×" character. Mouse users can dismiss; keyboard and screen-reader users may not.
CorrectTreat the close affordance as an explicit slot that must be implemented as a real button with an accessible name. Document that the Escape key triggers the same handler when `dismissible` is true.
- 03 Figma
Multiple stacked dialogs modeled as separate top-level Figma frames with no relationship between them
CodeStacked dialogs require z-index + correct `inert` toggling so only the topmost dialog is interactive
ConsequenceVisually the second dialog appears on top, but assistive tech may still read content from the first. Worse, focus trap collisions can leak focus back into the underlying dialog.
CorrectDocument that stacking is a separate behavioral concern, not just a layering concern. The canonical guidance is "avoid stacking modals; if you must, the topmost owns focus and inert all ancestors." Figma may render two stacked frames for illustration but the dev guidance is non-trivial.
- 04 Figma
Open/closed and transition states modeled as Figma variants
CodeData states (`opening`, `open`, `closing`, `closed`) driven by the application and CSS transitions
ConsequenceVariant explosion (size × variant × state). Designers may also forget transition states entirely and developers ship without enter/exit animations.
CorrectModel transition states once as data states in the canonical reference, with a single visual specification. Reserve Figma variants for the structurally different versions (`dialog` / `alertdialog` / `fullscreen`).