Terracotta
GitHub

It behaves exactly like Dialog: same focus trap, same Escape handling, same focus restoration. The difference is role="alertdialog", which tells assistive technology to announce the dialog's description immediately.

Use it when the user must respond before continuing. For ordinary modal content, use Dialog.

import {
  AlertDialog,
  AlertDialogPanel,
  AlertDialogOverlay,
  AlertDialogTitle,
  AlertDialogDescription,
} from 'terracotta/alert-dialog';
import { DisclosureStateChild, useDisclosureState } from 'terracotta/states';

Anatomy

<AlertDialog>                 {/* role="alertdialog" aria-modal */}
  <AlertDialogOverlay/>       {/* backdrop; closes on click */}
  <AlertDialogPanel>          {/* focus trap */}
    <AlertDialogTitle/>       {/* labels the dialog */}
    <AlertDialogDescription/> {/* describes the dialog, and is announced */}
  </AlertDialogPanel>
</AlertDialog>

An alert dialog should always have a description. Screen readers announce it, and it is what tells the user what they are agreeing to.

The dialog only points aria-labelledby and aria-describedby at a title or description that is mounted. Without an AlertDialogTitle, pass aria-label.

Examples

Confirming a destructive action

Focus lands on the safe choice, and the description is announced rather than the title alone.

Keeping the panel above the overlay

position: relative on the panel is not decoration. The overlay is position: fixed, and a positioned element paints above an unpositioned sibling whatever their order in the markup, so an unstyled panel ends up under the backdrop, dimmed and unclickable, and every click meant for it lands on the overlay and closes the dialog instead. Give the panel a position of its own, or a z-index, and the stacking follows the markup again.

Putting the safe choice first

The panel focuses its first focusable element. The order of the buttons therefore decides what Enter hits by default:

<div class="alertdialog-actions">
  {/* Focused first: the safe option */}
  <button type="button" onClick={() => setOpen(false)}>Cancel</button>
  <button type="button" class="danger" onClick={remove}>Delete permanently</button>
</div>

Reporting an unrecoverable error

<AlertDialog
  class="alertdialog"
  isOpen={failure() !== undefined}
  onClose={() => setFailure(undefined)}
>
  <AlertDialogOverlay class="alertdialog-overlay" />
  <AlertDialogPanel class="alertdialog-panel" data-tone="error">
    <AlertDialogTitle class="alertdialog-title">Upload failed</AlertDialogTitle>
    <AlertDialogDescription class="alertdialog-description">
      {failure()?.message}
    </AlertDialogDescription>
    <div class="alertdialog-actions">
      <button type="button" onClick={() => retry()}>Try again</button>
    </div>
  </AlertDialogPanel>
</AlertDialog>
.alertdialog-panel[data-tone="error"] {
  border-block-start: 4px solid #dc2626;
}

Uncontrolled

<AlertDialog class="alertdialog" defaultOpen>
  <AlertDialogOverlay class="alertdialog-overlay" />
  <AlertDialogPanel class="alertdialog-panel">
    <AlertDialogTitle class="alertdialog-title">Session expired</AlertDialogTitle>
    <AlertDialogDescription class="alertdialog-description">
      Sign in again to continue.
    </AlertDialogDescription>
    {({ close }) => <button type="button" onClick={close}>OK</button>}
  </AlertDialogPanel>
</AlertDialog>

Preventing dismissal

An alert dialog that must be answered should not close on a click away or on Escape. Use a plain element for the backdrop instead of the overlay, and disable the panel's key handling:

<AlertDialog class="alertdialog" isOpen={open()}>
  <div class="alertdialog-overlay" />
  <AlertDialogPanel class="alertdialog-panel" disabled>
    <AlertDialogTitle class="alertdialog-title">Accept the terms</AlertDialogTitle>
    <AlertDialogDescription class="alertdialog-description">
      You must accept the updated terms to continue.
    </AlertDialogDescription>
    <button type="button" onClick={() => accept()}>Accept</button>
  </AlertDialogPanel>
</AlertDialog>

disabled on the panel stops both Escape and the Tab trap. Use it only when the dialog has few controls. Otherwise focus can wander behind the modal.

With transitions

<Transition show={open()}>
  <AlertDialog class="alertdialog" isOpen={open()} onClose={() => setOpen(false)} unmount={false}>
    <TransitionChild
      enter="fade-enter" enterFrom="fade-from" enterTo="fade-to"
      leave="fade-leave" leaveFrom="fade-to" leaveTo="fade-from"
    >
      <AlertDialogOverlay class="alertdialog-overlay" />
    </TransitionChild>
    <TransitionChild
      enter="pop-enter" enterFrom="pop-from" enterTo="pop-to"
      leave="pop-leave" leaveFrom="pop-to" leaveTo="pop-from"
    >
      <AlertDialogPanel class="alertdialog-panel">…</AlertDialogPanel>
    </TransitionChild>
  </AlertDialog>
</Transition>
.fade-enter { transition: opacity 200ms ease-out; }
.fade-leave { transition: opacity 150ms ease-in; }
.fade-from  { opacity: 0; }
.fade-to    { opacity: 1; }

.pop-enter { transition: opacity 200ms ease-out, scale 200ms ease-out; }
.pop-leave { transition: opacity 150ms ease-in, scale 150ms ease-in; }
.pop-from  { opacity: 0; scale: 0.95; }
.pop-to    { opacity: 1; scale: 1; }

State attributes

Element Attribute Present when
AlertDialog tc-alert-dialog Always (whenever rendered)
AlertDialog tc-expanded The dialog is open
AlertDialog tc-disabled The dialog is disabled
AlertDialogOverlay tc-alert-dialog-overlay Always
AlertDialogOverlay tc-expanded, tc-disabled Mirrors the dialog
AlertDialogPanel tc-alert-dialog-panel Always
AlertDialogPanel tc-expanded, tc-disabled Mirrors the dialog
AlertDialogTitle tc-alert-dialog-title Always
AlertDialogTitle tc-expanded, tc-disabled Mirrors the dialog
AlertDialogDescription tc-alert-dialog-description Always
AlertDialogDescription tc-expanded, tc-disabled Mirrors the dialog

With the default unmount, the dialog is in the DOM only while open. tc-expanded therefore matters most when you set unmount={false}.

Styling

[tc-alert-dialog]:not([tc-expanded]) { display: none; }

[tc-alert-dialog-overlay] { position: fixed; inset: 0; background: rgb(0 0 0 / 0.45); }
[tc-alert-dialog-panel]   { background: #ffffff; border-radius: 0.75rem; padding: 1.5rem; }

/* An alert dialog usually wants a stronger entrance than a plain dialog */
@media (prefers-reduced-motion: no-preference) {
  [tc-alert-dialog-panel] { animation: alertdialog-in 180ms ease-out; }
}

@keyframes alertdialog-in {
  from { opacity: 0; scale: 0.94; }
  to   { opacity: 1; scale: 1; }
}

Reading the state in code

Identical to Dialog. It is the same disclosure state:

Member Type Description
isOpen() boolean Whether the dialog is open.
open() / close() () => void Opens / closes it.
toggle() () => void Flips the state.
disabled() boolean Whether the dialog is disabled.

Keyboard

Key Action
Escape Closes the dialog (handled by AlertDialogPanel, unless it is disabled)
Tab / Shift+Tab Cycles focus within the panel

API

<AlertDialog>

Owns the disclosure state and renders a <div> by default. Does not take a ref.

Prop Type Default Description
as ValidConstructor 'div' Element or component to render as.
defaultOpen boolean none Initial state, uncontrolled. Mutually exclusive with isOpen.
isOpen boolean none Current state, controlled. Mutually exclusive with defaultOpen.
disabled boolean false Blocks opening and closing.
onChange (state: boolean) => void none Called with the new state on every change.
onOpen () => void none Called when it opens, before onChange.
onClose () => void none Called when it closes, including via Escape and overlay clicks.
unmount boolean | 'offscreen' true How the dialog behaves while closed. See unmount.
children JSX.Element | (state: DisclosureStateProperties) => JSX.Element none Contents, or a render prop.
…rest props of as none Forwarded to the rendered element.

Rendered attributes:

  • role="alertdialog".
  • aria-modal="true" while open.
  • aria-labelledby and aria-describedby, each only while its AlertDialogTitle or AlertDialogDescription is mounted. Without a title, pass aria-label.
  • inert and aria-hidden="true" while closed. This only shows with unmount={false}, where the closed dialog stays in the DOM.

Your own props are applied last, so they override any of these.

While it is open, everything outside the dialog gets inert. Live regions, such as a Toaster, are left out so they can still announce. Content you made inert yourself is left as it is. Stacked dialogs each restore only what they changed, in any order.

<AlertDialogPanel>

The focus trap. It focuses its first focusable child. With no focusable child, it gets tabindex="-1" and focuses itself, so Escape still works. Renders a <div> by default.

Prop Type Default Description
as ValidConstructor 'div' Element or component to render as.
disabled boolean false Stops the panel's own key handling.
ref DynamicNode<T> | (el) => void none Handle to the rendered element.
children JSX.Element | (state: DisclosureStateProperties) => JSX.Element none Contents, or a render prop.
…rest props of as none Forwarded to the rendered element.

<AlertDialogOverlay>

The backdrop. Closes the dialog when clicked. Renders a <div> by default.

Prop Type Default Description
as ValidConstructor 'div' Element or component to render as.
ref DynamicNode<T> | (el) => void none Handle to the rendered element.
children JSX.Element | (state: DisclosureStateProperties) => JSX.Element none Contents, or a render prop.
…rest props of as none Forwarded to the rendered element.

<AlertDialogTitle>

The accessible name. Renders an <h2> by default.

Prop Type Default Description
as ValidConstructor 'h2' Element or component to render as.
ref DynamicNode<T> | (el) => void none Handle to the rendered element.
children JSX.Element | (state: DisclosureStateProperties) => JSX.Element none Title text, or a render prop.
…rest props of as none Forwarded to the rendered element.

<AlertDialogDescription>

The announced text. Renders a <p> by default. Does not take a ref.

Prop Type Default Description
as ValidConstructor 'p' Element or component to render as.
children JSX.Element | (state: DisclosureStateProperties) => JSX.Element none Description text, or a render prop.
…rest props of as none Forwarded to the rendered element.

Every descendant throws if rendered outside an <AlertDialog>.