Skip to content

Alert Dialog

Ask for confirmation before a destructive or hard-to-reverse action.

import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogMedia, AlertDialogOverlay, AlertDialogPortal, AlertDialogTitle, AlertDialogTrigger, ConfirmDialog } from '@youj/react'

Installation & usage

npm i @youj/react

For an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.

Use Alert Dialog before deleting a project, revoking a key or discarding unsaved work. Unlike Dialog, it stays open when you press outside. Reserve confirmation for actions that are hard to reverse. For reversible actions, offer Undo in a Toast.

ConfirmDialog is the whole pattern in one component. Its confirm button says what it does, it stays open while async work runs, and it can ask for a typed phrase before a wide destructive action.

import { useState } from 'react'
import { Button, ConfirmDialog } from '@youj/react'
import { TrashIcon } from '@youj/icons'

export function DeleteProject({ project }: { project: Project }) {
  const [open, setOpen] = useState(false)
  return (
    <>
      <Button variant="danger" onClick={() => setOpen(true)}>
        Delete…
      </Button>
      <ConfirmDialog
        open={open}
        onOpenChange={setOpen}
        destructive
        icon={<TrashIcon />}
        title={`Delete ${project.name}?`}
        description="Its deployments and domains go with it. This can’t be undone."
        confirmLabel="Delete project"
        challenge={project.name}
        onConfirm={() => deleteProject(project.id)}
      />
    </>
  )
}

destructive here is a boolean on ConfirmDialog; the trigger Button takes variant="danger".

To lay one out by hand, compose the parts:

import { useState } from 'react'
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
  Button,
} from '@youj/react'

const [open, setOpen] = useState(false)

<AlertDialog open={open} onOpenChange={setOpen}>
  <AlertDialogTrigger render={<Button variant="outline" />}>Close editor</AlertDialogTrigger>
  <AlertDialogContent>
    <AlertDialogHeader>
      <AlertDialogTitle>Discard changes?</AlertDialogTitle>
      <AlertDialogDescription>Your edits haven’t been saved.</AlertDialogDescription>
    </AlertDialogHeader>
    <AlertDialogFooter>
      <AlertDialogCancel>Keep editing</AlertDialogCancel>
      <AlertDialogAction
        variant="danger"
        // The action doesn't close the dialog by itself.
        onClick={() => {
          discard()
          setOpen(false)
        }}
      >
        Discard
      </AlertDialogAction>
    </AlertDialogFooter>
  </AlertDialogContent>
</AlertDialog>

Confirm and challenge

Name the action on the button: "Delete project" or "Delete 12 projects", never "OK" or "Yes". The label is the last thing people read before they commit.

challenge arms the confirm only once the exact phrase is typed, shown in a copyable chip above the field, and a check draws itself in the field when it matches. Reserve it for actions that are destructive and wide, like a bulk delete or anything with a name. On a small, common action it's friction that people learn to rush.

onConfirm decides how the dialog closes:

  • A plain function runs and the dialog closes.
  • A function that returns a promise keeps the dialog open while it runs. The confirm button shows a spinner and announces pendingLabel, Cancel and Escape are held, and the dialog closes when the promise resolves.
  • loading forces the busy state, for work that something else drives.

Reference

Propsopen, onOpenChange, title… (30)

Props

ConfirmDialog

ConfirmDialog props
PropType
openRequiredboolean–
onOpenChangeRequired(open: boolean) => void–
titleRequiredReactNode–
descriptionReactNode–
confirmLabelRequiredstring

What confirming does, in its own words: "Delete 12 projects". Never "OK".

–
cancelLabelstring

default "Cancel"

"Cancel"
onConfirmRequired() => unknown

Return a promise to keep the dialog open while the work runs: it closes when the promise resolves, and stays open with the error's message when it rejects, so the failure shows next to the choice that caused it.

–
destructiveboolean

A destructive confirm button and icon badge.

default false

false
iconReactNode

The icon in the badge beside the title.

–
challengestring

Escalates the confirm: it arms only once this exact phrase is typed. Reserve it for actions that are destructive and wide (many rows, or something named).

–
challengeLabelReactNode

The challenge field's label. Default: "Type this phrase to confirm", with the phrase in a copyable chip above the field.

–
consequencesReactNode[]

What will be lost, as a list under the description: counts, names.

–
pendingLabelstring

Announced while an async confirm runs.

default "Working"

"Working"
loadingboolean

Forces the loading state, for work driven elsewhere. An async onConfirm sets it by itself.

default false

false
finalFocusboolean | RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | HTMLElement | null | void)

Where focus goes once the dialog has closed: an element or ref, or a function that picks one (return false to leave focus where it is, say after moving it yourself). Default: back to whatever had focus when it opened. Base UI's finalFocus.

–
onOpenChangeComplete(open: boolean) => void

Runs when the open or close animation has finished. Base UI's onOpenChangeComplete.

–
childrenReactNode

Extra content between the description and the challenge, such as what will be affected.

–

AlertDialogContent

AlertDialogContent props
PropType
size'default' | 'sm'

sm centers a short question over two equal buttons.

default 'default'

'default'
Other props pass through: extends AlertDialogPrimitive.Popup.Props.

AlertDialogAction

AlertDialogAction takes every Button prop, including the async ones.

AlertDialogAction props
PropType
variantButtonVariant
'default''danger''ghost''outline''link''secondary'

default | secondary | outline | ghost | danger | link.

–
soundboolean

With sound on (YoujProvider’s sound), the button plays tap as it's pressed. false keeps this one silent.

default true

true
size'default' | 'sm' | 'lg' | 'xs' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'–

AlertDialogMedia

AlertDialogMedia props
PropType
variant'default' | 'danger'–

AlertDialogCancel takes Base UI's AlertDialog.Close props plus Button's variant (default outline) and size. AlertDialog, AlertDialogTrigger, AlertDialogTitle, AlertDialogDescription, AlertDialogOverlay and AlertDialogPortal pass their props through to Base UI. AlertDialogHeader and AlertDialogFooter are plain divs.

TouchOn a touch device an alert dialog stays a centered alert, the way a…

Touch

On a touch device an alert dialog stays a centered alert, the way a phone's own alerts are. Unlike Dialog, it doesn't become a sheet and can't be pulled away: it's asking a question, and a swipe isn't an answer. Its actions stack at full width with the primary one on top, each at least 44px tall, and show the same press feedback as every button. The small size keeps its two buttons side by side. Nothing in it depends on hover.

Opened by a tap, focus goes to the alert itself rather than to Cancel or a challenge field, so the on-screen keyboard comes up only when someone taps the field.

MotionEnter, exit, scrim… · reduced motion

Motion

Motion tokens
MomentTokens
Enterrises 6px and scales up from .96 as it fades in (yj-dialog)
duration.baseease.enter

220ms · cubic-bezier(0.16, 1, 0.3, 1)

220mscubic-bezier(0.16, 1, 0.3, 1)
Exit
duration.fastease.exit

140ms · cubic-bezier(0.4, 0, 1, 1)

140mscubic-bezier(0.4, 0, 1, 1)
Scrimfades in, then out on fast (yj-backdrop)
duration.baseease.standard

220ms · cubic-bezier(0.2, 0, 0, 1)

220mscubic-bezier(0.2, 0, 0, 1)
Challenge matchthe check draws itself in the field, then settles
duration.slowease.emphasizedspring.settle

340ms · cubic-bezier(0.6, 0, 0.1, 1) · 0.45s, bounce 0.3

340mscubic-bezier(0.6, 0, 0.1, 1)0.45s, bounce 0.3
Async labelAlertDialogAction with onAction: the label and the button width morph together
spring.snappy

0.2s, bounce 0

0.2s, bounce 0
Error shake4, 3 and 2px, then home
duration.slowease.standard

340ms · cubic-bezier(0.2, 0, 0, 1)

340mscubic-bezier(0.2, 0, 0, 1)

Reduced motion

Opens and closes instantly. The check appears drawn, spinners hold still, and an async label swaps without a morph or a shake, with its pending text in place of the spinner.

Reduced motion

The dialog appears and leaves at once. Everything that reports progress still does, without moving: the challenge's check appears already drawn, a pending confirm shows a still spinner, and onAction swaps its label for text, with no morph and no shake.

AccessibilityKeyboard, ARIA, focus

Accessibility

  • The popup has role="alertdialog". AlertDialogTitle names it and AlertDialogDescription describes it, so screen readers read the question and its consequence together.
  • In ConfirmDialog, focus starts on the least destructive control: Cancel, or the challenge field when there is one. When you compose the parts, focus starts on the first focusable control in the popup, so keep AlertDialogCancel before the action, as in the snippets. Enter from the field confirms once the phrase matches. Opened by a tap, focus starts on the alert itself.
  • A press outside neither closes the dialog nor moves focus. Escape cancels, unless async work is running. Focus returns to the element that opened it.
  • While a confirm runs, the button keeps focus and sets aria-busy, and pendingLabel is announced. A failure is announced assertively through role="alert". A matching phrase is announced as "Phrase matches".
  • The media badge is decorative (aria-hidden), so put the meaning in the title.