Dialog
shadcn Dialog (modal) - trigger, controlled state, forms inside dialogs, and accessibility.
Search across all documentation pages
shadcn Dialog (modal) - trigger, controlled state, forms inside dialogs, and accessibility.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
open and onOpenChange@radix-ui/react-dialog - fully accessible with focus trap and ESC closeDialogTrigger opens the dialog when clickedDialogContent renders in a portal, overlaying the page with a backdropDialogClose renders a button that closes the dialog when clickedasChild merges the component's props onto its single child elementConfirmation 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>// 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 attributesForm 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-auto to DialogContent.
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 onOpenChange when the dialog opens.
| 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 |
DialogTrigger alone; the Dialog manages its own open/closed stateopen and onOpenChange props to Dialog for full state controlasync function onSubmit(data: FormData) {
await saveData(data);
setOpen(false); // close only after success
}Use controlled state (open/onOpenChange) so you decide when to close.
@radix-ui/react-dialog<DialogContent
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
{/* User must explicitly click a close button */}
</DialogContent>onOpenChange callback when the dialog opensmax-h-[85vh] overflow-y-auto to DialogContent<Button>) replaces the default rendered elementasChild, DialogTrigger renders its own button wrapping your childinterface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
title: string;
description: string;
}DialogClose renders a button that automatically closes the dialog on clickButton with onClick={() => setOpen(false)} insteadconst [deleting, setDeleting] = useState(false);
async function handleDelete() {
setDeleting(true);
await onConfirm();
setDeleting(false);
setOpen(false);
}
// Disable both Cancel and Delete buttons while deletingDialogProps from @radix-ui/react-dialog for the root component propsDialogContent extends Radix's DialogContentProps and accepts all valid HTML div attributesReviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥