Dialog
shadcn Dialog (modal) — trigger, controlled state, forms inside dialogs, and accessibility.
Recipe
Quick-reference recipe card — copy-paste ready.
npx shadcn@latest add dialog button input labelimport {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle, DialogTrigger, DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
// Basic dialog
<Dialog>
<DialogTrigger asChild>
<Button>Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>This action cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>When to reach for this: When you need a modal overlay for confirmations, forms, detail views, or any content that requires the user's focused attention.
Working Example
"use client";
import { useState } from "react";
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const EditProfileSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
bio: z.string().max(200, "Bio must be under 200 characters").optional(),
});
type EditProfileData = z.infer<typeof EditProfileSchema>;
export function EditProfileDialog({
profile,
onSave,
}: {
profile: EditProfileData;
onSave: (data: EditProfileData) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<EditProfileData>({
resolver: zodResolver(EditProfileSchema),
defaultValues: profile,
});
async function onSubmit(data: EditProfileData) {
await onSave(data);
setOpen(false);
}
function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen);
if (nextOpen) {
reset(profile); // reset form when opening
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="outline">Edit Profile</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>
Update your profile information. Click save when you are done.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" {...register("name")} />
{errors.name && (
<p className="text-sm text-red-600">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" {...register("email")} />
{errors.email && (
<p className="text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<Input id="bio" {...register("bio")} placeholder="Tell us about yourself" />
{errors.bio && (
<p className="text-sm text-red-600">{errors.bio.message}</p>
)}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}What this demonstrates:
- Controlled dialog with
openandonOpenChange - Form inside dialog with Zod validation
- Form reset on dialog open
- Close dialog on successful submission
- Accessible labels and error messages
Deep Dive
How It Works
- Dialog is built on
@radix-ui/react-dialog— fully accessible with focus trap and ESC close DialogTriggeropens the dialog when clickedDialogContentrenders in a portal, overlaying the page with a backdrop- Focus is trapped inside the dialog while open — Tab cycles through focusable elements
- ESC key and backdrop click close the dialog (configurable)
DialogCloserenders a button that closes the dialog when clickedasChildmerges the component's props onto its single child element
Variations
Confirmation dialog with async action:
function DeleteConfirmDialog({
onConfirm,
itemName,
}: {
onConfirm: () => Promise<void>;
itemName: string;
}) {
const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
async function handleDelete() {
setDeleting(true);
await onConfirm();
setDeleting(false);
setOpen(false);
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">Delete</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete {itemName}?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the item.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={deleting}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
{deleting ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}Dialog with scrollable content:
<DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Terms of Service</DialogTitle>
</DialogHeader>
<div className="prose max-w-none text-sm">
{/* Long content */}
</div>
</DialogContent>Prevent close on backdrop click:
<DialogContent
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
{/* User must explicitly close */}
</DialogContent>TypeScript Notes
// Dialog component props
import type { DialogProps } from "@radix-ui/react-dialog";
// Controlled dialog wrapper
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
title: string;
description: string;
}
// DialogContent extends Radix DialogContentProps
// You can pass any valid HTML div attributesGotchas
-
Form submission closes dialog — If the form action navigates or the state changes, the dialog may close unexpectedly. Fix: Use controlled state and only close after the action completes.
-
Focus trap with portals — If you render a dropdown or popover inside a dialog, ensure it also portals correctly. shadcn components handle this automatically.
-
Mobile scrolling — Dialog content can overflow on small screens. Fix: Add
max-h-[85vh] overflow-y-autotoDialogContent. -
Multiple dialogs stacking — Opening a dialog from within a dialog works but can confuse users. Fix: Use a single dialog with dynamic content, or ensure proper z-index stacking.
-
Form reset on close — Dialog content stays mounted between opens by default. Fix: Reset form state in
onOpenChangewhen the dialog opens.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Sheet (side panel) | You want a slide-in panel instead of a centered modal | A centered dialog is more appropriate |
| Alert Dialog | The action is destructive and must not be accidentally dismissed | The dialog can be closed by clicking outside |
| Popover | You need a small, non-blocking overlay near a trigger | You need full-page focus lock |
| Drawer | You want a mobile-friendly bottom sheet | Desktop-first experience |
FAQs
What is the difference between an uncontrolled and controlled Dialog?
- Uncontrolled: use
DialogTriggeralone; the Dialog manages its own open/closed state - Controlled: pass
openandonOpenChangeprops toDialogfor full state control - Use controlled mode when you need to close programmatically (e.g., after form submission)
How do you close a Dialog after a successful form submission?
async function onSubmit(data: FormData) {
await saveData(data);
setOpen(false); // close only after success
}Use controlled state (open/onOpenChange) so you decide when to close.
What Radix library powers the shadcn Dialog?
@radix-ui/react-dialog- It provides focus trap, ESC key close, backdrop click close, and portal rendering
How do you prevent the Dialog from closing when the user clicks outside?
<DialogContent
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
{/* User must explicitly click a close button */}
</DialogContent>Gotcha: Why does form state persist between Dialog opens?
- Dialog content stays mounted between opens by default
- Old form values remain unless you explicitly reset
- Fix: reset form state in the
onOpenChangecallback when the dialog opens
How do you handle mobile overflow in a Dialog with long content?
- Add
max-h-[85vh] overflow-y-autotoDialogContent - This prevents the dialog from exceeding the viewport height on small screens
What does asChild do on DialogTrigger?
- It merges DialogTrigger's click handler and accessibility props onto its single child
- The trigger element (e.g.,
<Button>) replaces the default rendered element - Without
asChild, DialogTrigger renders its own button wrapping your child
How do you type a reusable confirmation dialog component?
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
title: string;
description: string;
}What is DialogClose and when should you use it?
DialogCloserenders a button that automatically closes the dialog on click- Use it for cancel buttons in uncontrolled dialogs
- In controlled dialogs, you can use a regular
ButtonwithonClick={() => setOpen(false)}instead
Gotcha: What happens when you open a dropdown or popover inside a Dialog?
- The focus trap can conflict with portaled overlays rendered outside the dialog
- shadcn components handle this automatically since they also portal
- Custom non-portaled overlays may get trapped or hidden behind the dialog
How do you implement a delete confirmation dialog with async loading state?
const [deleting, setDeleting] = useState(false);
async function handleDelete() {
setDeleting(true);
await onConfirm();
setDeleting(false);
setOpen(false);
}
// Disable both Cancel and Delete buttons while deletingWhat TypeScript type should you use for Dialog component props?
- Import
DialogPropsfrom@radix-ui/react-dialogfor the root component props DialogContentextends Radix'sDialogContentPropsand accepts all valid HTML div attributes