Lesson 2: Your First Dialog
Build a production-ready dialog, then learn when to choose controlled state and guarded dismissal.
Lesson 2 of 10: You will build a dialog that is realistic enough to ship, not just a demo.
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
| Part | Responsibility |
|---|---|
Root | Owns open state and context |
Trigger | Announces + toggles dialog |
Portal | Escapes local stacking/overflow context |
Overlay | Backdrop + outside interaction surface |
Content | Dialog semantics, focus lifecycle, dismiss orchestration |
Title / Description | Accessible announcement text |
Close | Explicit 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
EscapeviaonEscapeKeyDown. - block outside interactions via
onInteractOutside. - disable close controls.
This pattern avoids data loss and inconsistent UI states.
Modal vs non-modal
<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
- Open from keyboard and mouse.
- Tab cycles inside content.
Escapecloses only when allowed.- Focus returns to trigger on close.
- Title/description are announced.