Dialog

A modal dialog that interrupts the user with important content. Built on Radix UI with focus trap, escape handling, and scroll lock.

Form

Share link

Installation

pnpm
pnpm dlx @hex-core/cli add dialog

Controlled with onOpenChange guard

Block accidental close while the form is dirty. onOpenChange swallows the request to close and prompts the user instead

tsx
const [open, setOpen] = useState(false);
const [isDirty, setIsDirty] = useState(false);

function handleOpenChange(next: boolean) {
  if (!next && isDirty) {
    const confirmed = window.confirm("Discard your changes?");
    if (!confirmed) return;
    setIsDirty(false);
  }
  setOpen(next);
}

return (
  <Dialog open={open} onOpenChange={handleOpenChange}>
    <DialogTrigger asChild>
      <Button variant="outline">Edit profile</Button>
    </DialogTrigger>
    <DialogContent>
      <DialogHeader>
        <DialogTitle>Edit profile</DialogTitle>
        <DialogDescription>Update your name and bio.</DialogDescription>
      </DialogHeader>
      <div className="grid gap-4 py-4">
        <Input
          placeholder="Name"
          onChange={() => setIsDirty(true)}
        />
      </div>
      <DialogFooter>
        <Button onClick={() => { setIsDirty(false); setOpen(false); }}>Save</Button>
      </DialogFooter>
    </DialogContent>
  </Dialog>
);

Long content with scroll

Pin DialogHeader and DialogFooter outside a scrolling region so the title and primary actions stay visible while the body scrolls

tsx
<Dialog>
  <DialogTrigger asChild>
    <Button variant="outline">Read terms</Button>
  </DialogTrigger>
  <DialogContent className="max-w-2xl">
    <DialogHeader>
      <DialogTitle>Terms of service</DialogTitle>
      <DialogDescription>Last updated April 2026.</DialogDescription>
    </DialogHeader>
    <div className="max-h-[60vh] overflow-y-auto pr-2 text-sm leading-relaxed">
      {sections.map((section) => (
        <section key={section.id} className="py-3">
          <h3 className="font-medium">{section.title}</h3>
          <p className="mt-1 text-muted-foreground">{section.body}</p>
        </section>
      ))}
    </div>
    <DialogFooter>
      <Button variant="outline">Decline</Button>
      <Button>Accept</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

API Reference

PropTypeDefaultDescription
open
booleanControlled open state
defaultOpen
booleanfalseDefault open state for uncontrolled usage
onOpenChange
functionCallback fired when open state changes: (open: boolean) => void
modal
booleantrueWhen true, content outside the dialog is inert

AI Guidance

When to use

Use for focused, interruptive tasks: confirmations, quick forms, detail views. The user must address the dialog before continuing.

When not to use

Don't use for destructive confirmations (use AlertDialog). Don't use for complex multi-step flows (use a full page). Don't use for non-critical info (use Tooltip or Popover).

Common mistakes

  • Nesting dialogs inside each other
  • Forgetting DialogTitle (breaks accessibility — screen readers need it)
  • Using DialogDescription for long-form content (keep it short)
  • Putting too many primary actions in DialogFooter

Accessibility

Radix traps focus, handles Escape to close, and wires aria-labelledby/describedby to DialogTitle/DialogDescription. Always include a DialogTitle. DialogContent is constrained to `max-h-[calc(100vh-2rem)]` and scrolls internally so long content stays inside the focus trap.

Related components

Token budget: 600