---
title: "Dialog"
description: "A window that opens over the page and waits for an answer. It takes your keyboard and attention until you deal with it, and gives them back where it found them."
url: "https://opsinjs.pensievelabs.org/components/dialog"
source: "https://opsinjs.pensievelabs.org/components/dialog.md"
section: "Components"
status: "shipped"
kind: "component"
category: "surfaces"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["modal", "alert dialog", "popup", "confirm"]
usedIn: ["alert-escalation", "consent-flow", "diabetes-medicines-app"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="dialog"
  status="shipped"
  questions="[
  &#x22;`sheetBelow` is gone because a Sheet is dismissible, and whether an inset panel is the right phone answer stays open.&#x22;,
  &#x22;A refused Escape re-announces the question, never the reason, and nobody has put a screen reader on that announcement.&#x22;,
  &#x22;`actions` states a cap of two and cannot enforce it, because `ReactNode` does not say how many controls are inside.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="dialog" />

## Installation [#installation]

<ComponentInstall name="dialog" unbuilt="false" importPath="@/components/ui/dialog" dependencies="[&#x22;@base-ui/react&#x22;, &#x22;lucide-react&#x22;]" registryDependencies="[&#x22;surface&#x22;, &#x22;button&#x22;]" />

## Usage [#usage]

```tsx
import { Dialog } from "@/components/ui/dialog"
```

```tsx
<Dialog
  open={open}
  onOpenChange={setOpen}
  title="Delete this reading?"
  description="It will be removed from your history and from any trends it appears in."
  actions={
    <>
      <Button variant="destructive" onClick={remove}>Delete reading</Button>
      <Button variant="primary" onClick={() => setOpen(false)}>Keep it</Button>
    </>
  }
/>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;A decision the reader must make before anything else can proceed.&#x22;,
  &#x22;Confirming an action that cannot be undone or that the reader may not have anticipated.&#x22;,
  &#x22;A short blocking task, such as re-authenticating before revealing sensitive values.&#x22;,
]"
  avoid="[
  { case: &#x22;The reader could carry on without answering, or you are announcing, not asking.&#x22;, instead: &#x22;alert-banner&#x22; },
  { case: &#x22;A task the reader chose to start and can abandon.&#x22;, instead: &#x22;sheet&#x22; },
  { case: &#x22;Asking permission to collect or share health data.&#x22;, instead: &#x22;consent-sheet&#x22; },
  { case: &#x22;Confirming something that has already happened.&#x22;, instead: &#x22;toast&#x22; },
  { case: &#x22;Supporting detail attached to a control.&#x22;, instead: &#x22;popover&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="dialog"
  parts="[
  {
    name: &#x22;Dialog&#x22;,
    describes: &#x22;The portal root, holding the scrim and the panel.&#x22;,
    prop: &#x22;open&#x22;,
  },
  {
    name: &#x22;Dialog.Scrim&#x22;,
    describes: &#x22;The dimming layer, a Surface at the scrim rung.&#x22;,
  },
  {
    name: &#x22;Dialog.Viewport&#x22;,
    describes: &#x22;Fixed box; floats low on a phone, centred above sm.&#x22;,
  },
  {
    name: &#x22;Dialog.Container&#x22;,
    describes: &#x22;The popup. Carries the role, the name and the description.&#x22;,
    prop: &#x22;className&#x22;,
  },
  {
    name: &#x22;Dialog.Header&#x22;,
    describes: &#x22;Pinned. Holds the title and close control.&#x22;,
  },
  {
    name: &#x22;Dialog.Title&#x22;,
    describes: &#x22;An h2, always visible, and the accessible name.&#x22;,
    prop: &#x22;title&#x22;,
  },
  {
    name: &#x22;Dialog.Close&#x22;,
    describes: &#x22;Glyph and visible word from closeLabel. Absent when severity is alert.&#x22;,
    prop: &#x22;severity&#x22;,
  },
  {
    name: &#x22;Dialog.Body&#x22;,
    describes: &#x22;The scroll region, so the actions stay reachable at 200% text.&#x22;,
  },
  {
    name: &#x22;Dialog.Description&#x22;,
    describes: &#x22;What each answer does, and on an alert dialog why one is needed.&#x22;,
    prop: &#x22;description&#x22;,
  },
  {
    name: &#x22;Dialog.Content&#x22;,
    describes: &#x22;Optional. Nothing translucent goes in here.&#x22;,
    prop: &#x22;children&#x22;,
  },
  {
    name: &#x22;Dialog.Actions&#x22;,
    describes: &#x22;Pinned, least destructive last, at most two. Stacked below sm.&#x22;,
    prop: &#x22;actions&#x22;,
  },
]"
/>

## Examples [#examples]

### Confirming something that cannot be undone [#confirming-something-that-cannot-be-undone]

Destructive first, safest last, where `initialFocus="safest"` lands, so a stray
Return keeps the reading.

<ComponentPreview name="dialog-confirming-a-deletion" kind="example" align="start" />

### An answer is needed [#an-answer-is-needed]

`severity="alert"` sets `role="alertdialog"`, removes the close control, and
refuses both the scrim and Escape.

<ComponentPreview name="dialog-an-answer-is-needed" kind="example" align="start" />

## Content guidelines [#content-guidelines]

The title asks the question. The description says what each answer does, and on an
alert dialog why one is required. Labels are verbs naming the outcome: never *OK*
and *Cancel*, never a judgement about the reader.

<DoDont>
  <DoDont.Do>
    **"Delete this reading? It will be removed from your history. \[Delete reading]
    \[Keep it]"** Question, consequence, two verbs.
  </DoDont.Do>

  <DoDont.Dont>
    **"Are you sure? \[OK] \[Cancel]"** Sure about what, and which button does which?
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

Audited against WCAG 2.2 AA: a source pass and a rendered pass. The rendered pass
found no axe violations, no undersized target, no focus-visible miss, and held at
320px. The source pass found no critical, serious or moderate defect. The audit is
author-run, not independent, and clinical review is pending.

Confirmed:

* `severity="alert"` swaps in Base UI's alert-dialog root, tying the role to the
  refusal to dismiss.
* Named by its title, described by its description; a blank description renders
  nothing.
* Focus moves in on open, is trapped, and returns on close to whatever had it.
* The page behind is `aria-hidden`, not `inert`, so focus from behind can still
  reach it.

One residual: `Dialog.Close` uses a raw `focus-visible` utility, not the
border-focus token, but renders a valid 2px offset ring, so 2.4.7 and 1.4.11 pass;
it matches the reviewed `sheet` close control by design.

<KeyboardTable
  name="dialog"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus to the next control inside the dialog&#x22;,
    notes: &#x22;Trapped; initialFocus=\&#x22;safest\&#x22; wraps the first Tab to the top.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves focus back to the previous control&#x22;,
    notes: &#x22;Reverse order matches visual order.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Activates the focused action&#x22;,
    notes: &#x22;With initialFocus=\&#x22;safest\&#x22;, the answer that changes nothing.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Activates the focused action&#x22;,
    notes: &#x22;Consumed by the button. The page behind is scroll-locked.&#x22;,
  },
  {
    keys: &#x22;Escape&#x22;,
    action: &#x22;Closes an ordinary dialog and returns focus to whatever opened it&#x22;,
    notes: &#x22;The scrim dismisses the same way; both refuse when severity is alert.&#x22;,
  },
  {
    keys: &#x22;Escape&#x22;,
    action: &#x22;Does not close an alert dialog; moves focus to the dialog itself&#x22;,
    notes: &#x22;Re-reads the named, described container. The reason is not spoken.&#x22;,
  },
]"
/>

<ContrastReport component="dialog" />

## API reference [#api-reference]

<PropsTable name="DialogProps" />

`actions` and `closeLabel` are not in the specification; `closeLabel` defaults to
*Close*, ignored on an alert dialog. `onOpenChange` does not fire when Escape is
refused. `sheetBelow` is not a prop: the dialog never becomes a Sheet.

## Related [#related]

* [Sheet](./sheet.mdx): a place the reader chose to go and may leave; Dialog never becomes one.
* [AlertBanner](./alert-banner.mdx): announces without blocking a reader who can carry on.
* [ConsentSheet](./consent-sheet.mdx): a request with a consent contract and a recorded decision.
* [Popover](./popover.mdx): anchored and non-modal, dismissed by leaving rather than answering.
