React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

shadcndialogmodalcontrolledforms-in-dialogs

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 label
import {
  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 open and onOpenChange
  • 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
  • DialogTrigger opens the dialog when clicked
  • DialogContent renders 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)
  • DialogClose renders a button that closes the dialog when clicked
  • asChild merges 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 attributes

Gotchas

  • 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-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.

Alternatives

AlternativeUse WhenDon't Use When
Sheet (side panel)You want a slide-in panel instead of a centered modalA centered dialog is more appropriate
Alert DialogThe action is destructive and must not be accidentally dismissedThe dialog can be closed by clicking outside
PopoverYou need a small, non-blocking overlay near a triggerYou need full-page focus lock
DrawerYou want a mobile-friendly bottom sheetDesktop-first experience

FAQs

What is the difference between an uncontrolled and controlled Dialog?
  • Uncontrolled: use DialogTrigger alone; the Dialog manages its own open/closed state
  • Controlled: pass open and onOpenChange props to Dialog for 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 onOpenChange callback when the dialog opens
How do you handle mobile overflow in a Dialog with long content?
  • Add max-h-[85vh] overflow-y-auto to DialogContent
  • 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?
  • DialogClose renders 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 Button with onClick={() => 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 deleting
What TypeScript type should you use for Dialog component props?
  • Import DialogProps from @radix-ui/react-dialog for the root component props
  • DialogContent extends Radix's DialogContentProps and accepts all valid HTML div attributes
  • Button — dialog trigger buttons
  • Form — forms inside dialogs
  • Toast — feedback after dialog actions
  • Command — command palette as a dialog