Skip to main content

Lesson 2: Your First Dialog

Build a production-ready dialog, then learn when to choose controlled state and guarded dismissal.

Baseline implementation

import * as React from 'react'
import * as Dialog from '@gentleduck/primitives/dialog'
 
export function AccountDangerZone() {
  const [open, setOpen] = React.useState(false)
  const [busy, setBusy] = React.useState(false)
 
  async function handleDelete() {
    try {
      setBusy(true)
      // await api.deleteAccount()
      setOpen(false)
    } finally {
      setBusy(false)
    }
  }
 
  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Trigger className="rounded border px-3 py-2 text-sm">Delete account</Dialog.Trigger>
 
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out" />
 
        <Dialog.Content
          className="fixed left-1/2 top-1/2 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-xl border bg-white p-5 shadow-lg"
          onEscapeKeyDown={(event) => {
            if (busy) event.preventDefault()
          }}
          onInteractOutside={(event) => {
            if (busy) event.preventDefault()
          }}
        >
          <Dialog.Title className="text-lg font-semibold">Delete account?</Dialog.Title>
          <Dialog.Description className="mt-1 text-sm text-gray-600">
            This action is irreversible.
          </Dialog.Description>
 
          <div className="mt-4 flex justify-end gap-2">
            <Dialog.Close asChild>
              <button disabled={busy} className="rounded border px-3 py-2 text-sm">Cancel</button>
            </Dialog.Close>
            <button disabled={busy} onClick={handleDelete} className="rounded bg-red-600 px-3 py-2 text-sm text-white">
              {busy ? 'Deleting...' : 'Delete'}
            </button>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}
import * as React from 'react'
import * as Dialog from '@gentleduck/primitives/dialog'
 
export function AccountDangerZone() {
  const [open, setOpen] = React.useState(false)
  const [busy, setBusy] = React.useState(false)
 
  async function handleDelete() {
    try {
      setBusy(true)
      // await api.deleteAccount()
      setOpen(false)
    } finally {
      setBusy(false)
    }
  }
 
  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Trigger className="rounded border px-3 py-2 text-sm">Delete account</Dialog.Trigger>
 
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out" />
 
        <Dialog.Content
          className="fixed left-1/2 top-1/2 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-xl border bg-white p-5 shadow-lg"
          onEscapeKeyDown={(event) => {
            if (busy) event.preventDefault()
          }}
          onInteractOutside={(event) => {
            if (busy) event.preventDefault()
          }}
        >
          <Dialog.Title className="text-lg font-semibold">Delete account?</Dialog.Title>
          <Dialog.Description className="mt-1 text-sm text-gray-600">
            This action is irreversible.
          </Dialog.Description>
 
          <div className="mt-4 flex justify-end gap-2">
            <Dialog.Close asChild>
              <button disabled={busy} className="rounded border px-3 py-2 text-sm">Cancel</button>
            </Dialog.Close>
            <button disabled={busy} onClick={handleDelete} className="rounded bg-red-600 px-3 py-2 text-sm text-white">
              {busy ? 'Deleting...' : 'Delete'}
            </button>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  )
}

Anatomy and responsibilities

PartResponsibility
RootOwns open state and context
TriggerAnnounces + toggles dialog
PortalEscapes local stacking/overflow context
OverlayBackdrop + outside interaction surface
ContentDialog semantics, focus lifecycle, dismiss orchestration
Title / DescriptionAccessible announcement text
CloseExplicit close action

Controlled vs uncontrolled

Choose uncontrolled for simple local interactions.

Choose controlled when:

  • closing/opening depends on async state,
  • analytics or audit logging is required,
  • route/query params reflect open state,
  • permissions or validation gates apply.

Guarded dismiss flow

Prevent close while critical work runs:

  • block Escape via onEscapeKeyDown.
  • block outside interactions via onInteractOutside.
  • disable close controls.

This pattern avoids data loss and inconsistent UI states.


<Dialog.Root modal={false}>
<Dialog.Root modal={false}>

Use non-modal only for utility surfaces that should not trap focus or hide page semantics. For destructive workflows, keep modal behavior.


QA checks

  1. Open from keyboard and mouse.
  2. Tab cycles inside content.
  3. Escape closes only when allowed.
  4. Focus returns to trigger on close.
  5. Title/description are announced.