Designer 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.

Highlight
Fig 1.1 · Modal · Designer view

Implementations

How specific libraries realise the canonical anatomy. Each entry records the deltas between the canon and the library's surface.

cdk 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).

headlessui 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.

radix 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.

Designer

Figma anatomy

Slot Figma type Hint
backdrop frame Full-bleed frame with semi-transparent fill below the container
container frame Centered auto-layout frame; size variant drives width
header frame Auto-layout horizontal frame; padding from container token
title text Heading text style; bound to a component property for content
close-button instance Icon button instance; bound to component slot for variant swap
body frame Auto-layout vertical frame; min-height drives "comfortable" density
footer frame Auto-layout horizontal frame; right-aligned buttons by default
Designer

Token usage per slot

backdrop
color
  • backgroundcolor.surface.scrim
container
spacing
  • paddingspacing.comfortable
radius
  • cornerradius.lg
color
  • backgroundcolor.surface.raised
  • bordercolor.border.subtle
elevation
  • shadowelevation.overlay
header
spacing
  • paddingspacing.compact
  • gapspacing.compact
title
color
  • foregroundcolor.text.primary
typography
  • sizetext.lg
  • weightweight.semibold
  • lineHeightleading.tight
close-button
spacing
  • paddingspacing.tight
radius
  • cornerradius.sm
color
  • foregroundcolor.text.muted
  • ringcolor.border.focus
body
spacing
  • paddingspacing.comfortable
color
  • foregroundcolor.text.primary
typography
  • sizetext.md
  • lineHeightleading.normal
footer
spacing
  • paddingspacing.compact
  • gapspacing.compact
Both

Figma ↔ Code property map

FigmaTypeCodeNotes
VariantVariantvariantMaps dialog / alertdialog / fullscreen.
SizeVariantsizesm / md / lg / xl. Ignored in fullscreen variant.
TitleTexttitle
Has DescriptionBooleandescriptionSlot-visibility toggle; controls whether the body's first paragraph wires `aria-describedby`.
DescriptionTextdescription
BodyInstance SwapbodySwaps the body content slot (form, prose, custom layout). Code receives this as children of the dialog content.
Has FooterBooleanfooter
FooterInstance SwapfooterAction group instance — typically Button-Group.
DismissibleBooleandismissibleIn Figma controls visibility of the close button and the backdrop's pointer-event affordance. In code drives Escape, backdrop-click, and close-button behavior.
Scroll BehaviorVariantscrollBehaviorinside / outside. Figma encodes the visual difference; code conditionally pins header/footer.
Designer

Motion

TransitionDuration token
openmotion.duration.base
closemotion.duration.fast
backdropmotion.duration.fast
Easing
motion.easing.standard
Reduced motion
Instant (jump cut)
Designer

Responsive behaviour

BreakpointChange
breakpoint.smAt and below, the `dialog` and `alertdialog` variants render as the `fullscreen` variant by default — container fills the viewport, backdrop is suppressed (the dialog is the page), header pins to the top inset, footer pins to the bottom inset above the safe-area. Size property (`sm | md | lg | xl`) is ignored.
breakpoint.mdAbove this width, all three variants render as authored; the explicit `fullscreen` variant continues to fill the viewport but non-fullscreen variants honour their `size` property and centre with backdrop padding.
Both

Internationalisation

RTL · mirroring

Container alignment stays centered (modal centering is direction-neutral). Close button moves from inline-end of the header (visual right in LTR, visual left in RTL) — implemented via `inset-inline-end` on the close-button slot, not hard-coded `right`. Footer button order reverses logically: the primary commit action stays on inline-end (visual right in LTR, visual left in RTL); cancel on inline-start. Backdrop scrim is direction-neutral. Title and body inherit document direction; a mixed-direction document (e.g. Hebrew title with English body) still renders correctly because each text node carries its own `dir` attribute.

Text expansion

Title can grow into a second line; body has natural overflow with inside-scroll behaviour. Footer buttons may wrap to a second row on narrow viewports under heavy expansion (German "Veröffentlichen" next to "Abbrechen" forces wrap on `breakpoint.sm`). Size variant `xl` accommodates the longest expansions; alert-dialog title should favour shorter phrasing to avoid wrap on small viewports.

Both

Variants, properties, states

Variants

Structurally different versions of the component.

dialogalertdialogfullscreen

Properties

The same component, parameterised.

PropertyType
sizesm | md | lg | xl
dismissibleboolean
scrollBehaviorinside | outside

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
focus-visible
data
openingopenclosingclosed
Both

State transitions

FromToTrigger
closedopeningUser activates the trigger that owns the dialog (button click, link activation, programmatic `showModal()`); the previously- focused element is captured for restoration on close.
openingopenThe 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`.
openclosingUser dismisses via the close button, Escape, or — when `dismissible` is true — backdrop click; or the primary action commits and programmatically requests close.
closingclosedThe 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.
Both

Figma↔Code mismatches

  1. 01
    Figma

    A "modal" component drawn as a centered rectangle with a separate "backdrop" rectangle stacked on the same page

    Code

    A portal that lifts the dialog out of the DOM tree, with focus trapping, escape key handling, and `inert` on the rest of the document

    Consequence

    The 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.

    Correct

    Treat 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.

  2. 02
    Figma

    Close icon drawn as a static glyph in the header

    Code

    An accessible <button> with an ARIA label and matching escape-key handler

    Consequence

    The icon is announced as nothing or as the literal "×" character. Mouse users can dismiss; keyboard and screen-reader users may not.

    Correct

    Treat 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.

  3. 03
    Figma

    Multiple stacked dialogs modeled as separate top-level Figma frames with no relationship between them

    Code

    Stacked dialogs require z-index + correct `inert` toggling so only the topmost dialog is interactive

    Consequence

    Visually 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.

    Correct

    Document 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.

  4. 04
    Figma

    Open/closed and transition states modeled as Figma variants

    Code

    Data states (`opening`, `open`, `closing`, `closed`) driven by the application and CSS transitions

    Consequence

    Variant explosion (size × variant × state). Designers may also forget transition states entirely and developers ship without enter/exit animations.

    Correct

    Model 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`).

Designer

Common mistakes

#modal-no-focus-trap

Tab key escapes the dialog into the page beneath

Problem

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.

Fix

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

Problem

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.

Fix

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-aria-hidden-leak

Setting `aria-hidden` on the body without scoping out the dialog

Problem

Applying `aria-hidden="true"` to `<body>` to keep the background out of the AT tree also hides the dialog itself, because the dialog is typically a descendant of body in the DOM.

Fix

Either portal the dialog out of body (so the hidden subtree excludes it), or use `inert` (which does not collapse the subtree from the AT tree but disables interaction). Modern stacks should prefer `inert` on siblings of the portal.

#modal-escape-not-bound

Escape key does not dismiss a dismissible dialog

Problem

`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.

Fix

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

Problem

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.

Fix

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.

Accessibility hints
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.